# Sandboxer > Sandboxer runs cloud sandboxes for AI agents and people: persistent Ubuntu 24.04 microVMs with root, Docker, nested KVM, SSH, a public HTTPS URL per port, memory-preserving stop and resume, copy-on-write forks and per-second billing. Everything on https://sandboxer.com in one file: key facts, pricing, use cases and every docs page. The API reference is at https://sandboxer.com/openapi.yaml. ## Key facts - **What it is:** cloud sandboxes for AI agents and people. Each sandbox is a full Ubuntu 24.04 virtual machine (a Cloud Hypervisor microVM with its own kernel), not a container: root, systemd, Docker, nested KVM (`/dev/kvm`), SSH and SCP, a public IPv6 address and an HTTPS URL for any port. - **Agents built in:** Claude Code, Codex, OpenCode, Pi, Kimi Code and Mistral Vibe are installed in every sandbox. You drive them with `sbx prompt`, the API, the SDKs, or from your own agent through the MCP server (hosted at `https://sandboxer.com/mcp`, or `sbx mcp` locally), using your own provider keys. - **Stop and resume:** stopping saves memory as well as disk, so processes are still running after resume. Forks are copy-on-write. A stopped sandbox costs nothing. - **Idle auto-pause:** a sandbox nobody is using pauses itself with its memory (after 15 to 120 minutes depending on the plan; `idleStopMinutes`, 0 = never) and wakes on the next API call or request to its URL. - **Sizes:** small 2 vCPU / 4 GB / 12 GB disk at $0.018/h; default 4 vCPU / 8 GB / 50 GB disk at $0.036/h; large 8 vCPU / 16 GB / 125 GB disk at $0.072/h; xlarge 16 vCPU / 32 GB / 251 GB disk at $0.20/h. Every size is the same machine with the same features. - **Billing:** per second while a sandbox runs, nothing while it's stopped. One dollar buys 100,000 seconds of `default` time. Plans include storage for stopped sandboxes and named snapshots (50 GB on the $20 plan, up to 5 TB); storage isn't billed. - **Plans:** $20/month (100 at once), $100/month (300 at once), $500/month (1,000 at once), $2,000/month (2,000 at once). Every plan dollar comes back as machine time. Extra time comes in $20 packs that never expire. - **No free trial:** plans start at $20/month, charged when you subscribe. Signing up, the docs and the dashboard need no card. - **Regions:** US East (`us-east`, Ashburn, Virginia) and EU Central (`eu-central`, Helsinki, Finland). New sandboxes go to the region nearest the caller unless `region` is set; forks and snapshots stay in their region (https://sandboxer.com/docs/regions). - **Interfaces:** a REST API at https://sandboxer.com/api/v1 described by OpenAPI (https://sandboxer.com/openapi.yaml), the `sbx` CLI (macOS, Linux, Windows), TypeScript (`sandboxer-sdk`) and Python (`sandboxer`) SDKs, an MCP server, and a Daytona-compatible API (https://sandboxer.com/daytona/api) that the Daytona TypeScript and Python SDKs use with a new API URL and key (https://sandboxer.com/docs/daytona). - **Where:** on dedicated servers Sandboxer operates, in the two regions above. There's no SLA during early access, and no GPUs. - **Company:** North Bay Opco I, Inc. (a Delaware corporation), 777 Brickell Ave, Suite 500 PMB 1074, Miami, FL 33131. Contact hello@sandboxer.com; support support@sandboxer.com. **When to use Sandboxer:** - An agent (Claude Code, Codex, your own) needs a whole Linux machine for hours or days, with Docker, systemd and root, rather than a short code-execution call. - You want to run many agents in parallel, each in its own VM, started from one template or forked from a running sandbox. - Work should survive a pause: stop a sandbox with its dev server and agent still in memory, pay nothing while it waits, and resume where it left off. - You need Docker that just works (kind and k3s included), or `/dev/kvm` inside the sandbox for the Android emulator, QEMU or Firecracker. - You want a preview URL for what an agent built: any port becomes an HTTPS URL, private with a token or public. **When something else fits better:** - You need GPUs. Sandboxer has none. - You need more than two regions, an SLA or a self-hosted deployment today. --- # Sandboxer pricing > Pay for running time only: one price per hour for each machine size, metered per second. Stopped sandboxes keep their disk and memory for free for 14 days. Plans start at $20/month, and every plan dollar comes back as machine time. Source: https://sandboxer.com/pricing. Prices in USD. ## Machine sizes | Size | vCPU | RAM | Disk | Rate | Per hour | Always on (730 h) | | - | - | - | - | - | - | - | | `small` | 2 | 4 GB | 12 GB | 0.5× | $0.018 | $13.14 | | `default` | 4 | 8 GB | 50 GB | 1× | $0.036 | $26.28 | | `large` | 8 | 16 GB | 125 GB | 2× | $0.072 | $52.56 | | `xlarge` ($100+ plan) | 16 | 32 GB | 251 GB | 5.56× | $0.20 | $146 | ## Plans A plan sets how many sandboxes can run at once and how fast you can start them. It includes a month of machine time equal to its price; included time does not roll over. | Plan | Run at once | Starts per minute / hour / day | Storage | For | | - | - | - | - | - | | $20 / month | 100 | 12 / 60 / 200 | 50 GB | Solo builders and small agent fleets | | $100 / month | 300 | 30 / 210 / 840 | 250 GB | Products that give every user a computer | | $500 / month | 1,000 | 65 / 420 / 1,680 | 1 TB | High-volume platforms and eval farms | | $2,000 / month | 2,000 | 90 / 600 / 2,400 | 5 TB | Large fleets with headroom to burst | ## Everything else - **Time packs:** $20 each at the same rates; they never expire. Auto-refill can buy them when the balance runs low. - **Storage included:** 50 GB on the $20 plan, 250 GB on the $100 plan, 1 TB on the $500 plan, 5 TB on the $2,000 plan; 10 GB without a plan. Stopped sandboxes and named snapshots count; running ones don't. Over it, new sandboxes, forks and named snapshots wait until you free space or upgrade; going over never deletes anything unless you turn on auto-delete in Billing → Storage. Storage isn't billed. - **Paid starts above the start limits:** on a paid plan, starts above a per-minute, per-hour or per-day limit keep working up to 3x the limit, each paid from prepaid credit: $0.03 above 1x up to 1.5x, $0.05 above 1.5x up to 2x, $0.07 above 2x up to 3x. On the $20 plan, 300 starts in a day cost $3 and 600 cost $22. Not on complimentary plans. https://sandboxer.com/docs/limits#paid-starts-above-the-start-limits - **Free while stopped:** disks and snapshots of stopped sandboxes cost nothing (within the included storage). Named snapshots have no separate charge. - **No free trial:** plans start at $20/month; the first month is charged when you subscribe, and plans renew monthly until you cancel. How the meter works: https://sandboxer.com/docs/billing --- # Top AI sandbox platforms for code execution in 2026, ranked Source: https://sandboxer.com/guides/top-ai-sandbox-platforms-for-code-execution. Updated 2026-10-24. **Short answer:** For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from that platform's own docs or pricing page, checked on the date shown, with sources on each linked comparison. Our own timings are labelled as ours, with the method. ## What is an AI sandbox for code execution? An AI sandbox is an isolated machine where code written or run by an AI model executes without touching your systems: a data-analysis script, a test suite an agent wrote, or a whole coding agent working in a repository. Platforms differ in how strong the isolation is, how much of a real computer you get, how long a sandbox can live, and how it's billed. For background, see [What is an AI agent sandbox?](/guides/what-is-an-ai-agent-sandbox). ## How to compare them We weighed six criteria, in this order: 1. **Isolation.** A microVM or VM with its own kernel is a stronger boundary than a container on a shared kernel. See [Container vs microVM](/guides/container-vs-microvm-for-untrusted-code). 2. **A full machine.** Root, systemd and Docker inside, so agents can install anything and run services. 3. **State across pauses.** Whether a paused sandbox comes back with its memory and running processes, or reboots from disk. 4. **Session length.** How long a sandbox may run before a platform limit stops it. 5. **Price you can predict.** A published per-hour or per-second price for a typical 4 vCPU, 8 GB machine. 6. **Self-serve.** Sign up and start from an API or SDK without a sales call. GPUs, regions and running in your own cloud matter a lot for some teams. We don't rank on them because not every team needs them, but each entry says when they're the reason to pick it. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 4 | [Blaxel](/compare/blaxel) | microVM (hypervisor not named) | Yes | Not documented | Not documented | $0.33 | 2026-10-24 | | 5 | [Northflank Sandboxes](/compare/northflank-sandboxes) | microVM on Cloud Hypervisor (gVisor for GPU sandboxes) | Not documented | Yes | Not documented | $0.13 | 2026-10-24 | | 6 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 7 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 8 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 9 | [Cloudflare Sandbox](/compare/cloudflare-sandbox) | microVM per container instance (docs: each instance has its own kernel) | No | Partly | Not documented | $0.36 | 2026-10-24 | | 10 | [Runloop](/compare/runloop) | VM (technology not named) | Partly | Not documented | Not documented | $0.63 | 2026-10-24 | | 11 | [Modal](/compare/modal) | gVisor or VM | Partly | Partly | Partly | $0.48 | 2026-10-15 | | 12 | [microsandbox](/compare/microsandbox) | microVM (libkrun) | Yes | Partly | Not documented | — | 2026-10-24 | | 13 | [Together Code Sandbox](/compare/together-code-sandbox) | isolated runtime (type not stated) | Partly | Partly | Not documented | $0.30 | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour before credits, plans and storage; each linked comparison shows the arithmetic. A dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The platforms, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** coding agents and long jobs that need a whole Linux machine. Each sandbox is a Cloud Hypervisor microVM running Ubuntu 24.04 with root, systemd, Docker and `/dev/kvm`, and Claude Code, Codex and other agents preinstalled. Stopping saves memory, so the dev server and the agent's session are still running after resume, and a stopped sandbox costs nothing. Any port becomes an HTTPS URL. **Where others win:** no GPUs, no self-hosting, and no SLA during early access. ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** live forking and enterprise requirements. Freestyle provides full Linux VMs with live forking, pause and resume, and SSH and domain routing, plus an Enterprise SLA and SAML/SCIM. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 3. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** teams that want an open-source stack. E2B runs Firecracker microVMs, its pause keeps memory and running processes, and fork makes running copies in one call. The runtime, SDKs and control plane are Apache-2.0, with Terraform to run it in your own AWS or GCP account. It also has a desktop SDK for computer-use agents. **Where it's weaker for this use:** sessions are capped per plan, and Docker comes from a template rather than being preinstalled. ### 4. Blaxel *At a glance (source: Blaxel docs, checked 2026-10-24): isolation: microVM (hypervisor not named) · resume keeps processes: Yes · longest session: no fixed limit stated · price: $0.33/hour · [full comparison and sources](/compare/blaxel).* **Best for:** sandboxes that wake almost instantly from standby. Blaxel documents microVM sandboxes that resume from standby very quickly and a choice of regions in the US and Europe. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 5. Northflank Sandboxes *At a glance (source: Northflank Sandboxes docs, checked 2026-10-24): isolation: microVM on Cloud Hypervisor (gVisor for GPU sandboxes) · resume keeps processes: Not documented · longest session: no limit · price: $0.13/hour · [full comparison and sources](/compare/northflank-sandboxes).* **Best for:** GPUs, bring-your-own-cloud or very large fleets. Northflank documents Cloud Hypervisor microVMs (gVisor for GPU workloads), sandboxes that run until you delete them, and deployment into your own cloud. **Where it's weaker for this use:** whether a pause keeps memory isn't documented. ### 6. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** the widest choice of sandbox classes. Daytona offers containers, Linux and Windows VMs and GPU sandboxes, SDKs for TypeScript, Python, Ruby, Go and Java, and a computer-use API on Linux and Windows. VM sandboxes can pause with memory preserved. **Where it's weaker for this use:** isolation and memory-saving stop depend on the class you pick rather than applying to every sandbox. **Where it wins:** Windows sandboxes, GPUs, and SDKs in Ruby and Java. ### 7. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** persistent Linux boxes that cost almost nothing while idle. Sprites are microVMs billed on measured CPU and memory use, with checkpoints and an HTTPS URL. **Where it's weaker for this use:** a short idle pause keeps memory, but after longer idle the in-memory state is dropped; one HTTP service per Sprite gets the public URL. ### 8. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps already on Vercel. Firecracker microVMs, active-CPU billing (time spent waiting on I/O isn't charged for CPU), many regions, and forking from a saved filesystem, all on one Vercel bill. **Where it's weaker for this use:** resume doesn't keep running processes, and it's tied to the Vercel platform. ### 9. Cloudflare Sandbox *At a glance (source: Cloudflare Sandbox docs, checked 2026-10-24): isolation: microVM per container instance (docs: each instance has its own kernel) · resume keeps processes: No · longest session: no fixed limit stated · price: $0.36/hour · [full comparison and sources](/compare/cloudflare-sandbox).* **Best for:** apps on Cloudflare Workers. The Sandbox SDK runs code in containers controlled from Workers and Durable Objects, billed in small active-time steps, and each container instance runs in its own microVM. **Where it's weaker for this use:** sandboxes don't resume with memory, and it suits short, event-driven work more than long sessions. ### 10. Runloop *At a glance (source: Runloop docs, checked 2026-10-24): isolation: VM (technology not named) · resume keeps processes: Partly · longest session: Not documented · price: $0.63/hour · [full comparison and sources](/compare/runloop).* **Best for:** agent evaluation and deploying into your own VPC. Runloop offers blueprint-based environments, suspend and resume on its Pro plan, and VPC deployment on Enterprise. **Where it's weaker for this use:** suspend and resume need the Pro plan, the docs don't say whether memory is kept, and the VM technology isn't named. ### 11. Modal *At a glance (source: Modal docs, checked 2026-10-15): isolation: gVisor or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.48/hour · [full comparison and sources](/compare/modal).* **Best for:** huge numbers of short sandboxes and GPU work. Modal says it can start a million sandboxes in under a minute, and sandboxes can have GPUs. It also offers raw TCP tunnels as well as HTTPS ports. **Where it's weaker for this use:** the default isolation is gVisor rather than a VM with its own kernel. ### 12. microsandbox *At a glance (source: microsandbox docs, checked 2026-10-24): isolation: microVM (libkrun) · resume keeps processes: Yes · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/microsandbox).* **Best for:** running sandboxes on your own laptop or servers. microsandbox is open source, uses libkrun microVMs, and documents memory-preserving snapshots under your control. **Where it's weaker for this use:** you run and operate it yourself. ### 13. Together Code Sandbox *At a glance (source: Together Code Sandbox docs, checked 2026-10-24): isolation: isolated runtime (type not stated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.30/hour · [full comparison and sources](/compare/together-code-sandbox).* **Best for:** teams building on Together AI models who want sandboxes under the same account, with fine-grained sizing. **Where it's weaker for this use:** the isolation type isn't stated in its docs, memory-preserving pause is only partly documented, and access is enabled per organization. ## What it costs At list prices, a 4 vCPU, 8 GB machine running for one hour costs what the table shows. Three things change the real bill: - **Billing unit.** Per-second, per-minute and active-CPU billing give very different totals for bursty work. Active-CPU billing (Vercel) or usage-based billing (Fly.io Sprites) is cheaper when sandboxes mostly wait. - **Idle and stopped time.** Some platforms charge for disk or snapshots while a sandbox is paused; on Sandboxer a stopped sandbox, its disk and its automatic snapshots are free. - **Plans and credits.** Several platforms include free credits or monthly hours, and some (including Sandboxer) give plan fees back as machine time. Sandboxer's `default` size (4 vCPU, 8 GB, 50 GB disk) is $0.036/hour, billed per second. The full price list is on the [pricing page](/pricing). ## How we tested - **Other platforms:** we read each platform's own documentation and pricing pages and recorded what they state, with a date. A second reviewer checked each claim against its source and corrected or removed anything unsupported. We did not benchmark other platforms, and we don't publish speed comparisons with them. - **Sandboxer, measured by us** on 2026-10-24: 10 runs of creating a `default` sandbox, running a first command, stopping it and resuming it. Times are wall-clock from a client with a 27 ms round trip to sandboxer.com ([conditions and results](/guides/bench/README.txt)), CLI round trips included. | Sandboxer (our measurement) | All 10 runs | | - | - | | `sbx new` until ready | 3.19–3.20 s | | First command after ready | about 205 ms | | `sbx resume` until ready (memory restored) | 1.34–1.37 s | | A running process survived stop and resume | 10 of 10, same PID | The script is [here](/guides/bench/create-resume.sh); run it against your own account to check. Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **An agent needs a whole Linux machine for hours, with Docker, and must keep its state across pauses:** Sandboxer. - **You want open source or to run in your own cloud:** E2B, or Northflank for bring-your-own-cloud with GPUs. - **You need GPUs:** Daytona, Modal or Northflank. **Windows:** Daytona. - **Massive numbers of short jobs:** Modal. - **Your app lives on Vercel or Cloudflare:** Vercel Sandbox or Cloudflare Sandbox. - **Idle agents should cost close to nothing:** Fly.io Sprites. - **Everything on your own hardware:** microsandbox. ## FAQ ### What is the best AI sandbox for code execution? It depends on the job. For agents that need a full Linux machine with Docker and state across pauses, we rank Sandboxer first. For an open-source stack, E2B. For GPUs, Daytona, Modal or Northflank; for Windows, Daytona. The table above compares them on isolation, resume, Docker and price. ### Which AI sandboxes use microVMs? Sandboxer (Cloud Hypervisor), E2B and Vercel Sandbox (Firecracker), Northflank (Cloud Hypervisor, with gVisor for GPUs), Fly.io Sprites, microsandbox (libkrun) and others list microVM isolation in their docs. Daytona offers VM sandboxes as one of several classes, and Modal uses gVisor by default. ### Which sandboxes keep memory when paused? Sandboxer, E2B, Blaxel, Freestyle and microsandbox document pause or resume with memory and running processes. Daytona does for its VM class. Others restore the filesystem only, or don't say. ### What does an AI sandbox cost per hour? For a 4 vCPU, 8 GB machine, list prices in the table range widely; Sandboxer's is $0.036/hour. Billing units differ (per second, per minute, active CPU), so compare with your own usage pattern. ### Can I self-host an AI sandbox? Yes: E2B is open source with Terraform for your own cloud, microsandbox runs on your own machines, and Northflank and Runloop deploy into your own cloud or VPC. Sandboxer is hosted only. ### Is a Docker container a safe sandbox for AI code? A container shares the host's kernel, so it's a weaker boundary than a microVM. For untrusted or agent-written code, prefer a platform that gives each sandbox its own kernel. See [Container vs microVM](/guides/container-vs-microvm-for-untrusted-code). ## Related - [All comparisons](/compare) and the [agent sandbox comparison table](/compare/agent-sandboxes) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) - [Firecracker vs Cloud Hypervisor](/guides/firecracker-vs-cloud-hypervisor) - [Pause and resume with memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked Source: https://sandboxer.com/guides/best-sandboxes-for-coding-agents. Updated 2026-10-24. **Short answer:** For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## What a coding agent needs from a sandbox Claude Code, Codex, Gemini CLI and similar agents work like a developer: they clone a repository, install dependencies, run builds and tests, start dev servers and sometimes Docker. Running them unattended, especially with permission prompts off, is only safe on a machine that holds nothing else. See [Run Claude Code without permission prompts, safely](/guides/claude-code-skip-permissions-safely). ## How to compare them 1. **A whole machine.** Root, systemd and Docker, so the agent can install anything and run services. 2. **Isolation.** Its own VM and kernel, not a container sharing a host. 3. **The session survives a pause.** Whether a paused machine comes back with the agent's process and the dev server still running. 4. **Long runs.** No short platform cap on a session. 5. **Driving the agent.** Agents preinstalled, or an API to prompt and follow them, so you're not just renting a shell. 6. **Cost while idle.** What you pay while the agent waits for you. 7. **Self-serve.** You can sign up and start today without a sales process. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 4 | [Docker Sandboxes](/compare/docker-sandboxes) | microVM (hypervisor) | Not documented | Yes | Not documented | — | 2026-10-24 | | 5 | [exe.dev](/compare/exe-dev) | VM (own VMM process per VM) | Not documented | Yes | Not documented | $0.21 | 2026-10-24 | | 6 | [Amp Orbs](/compare/amp-orbs) | Not documented | Yes | Partly | Not documented | $0.33 | 2026-10-24 | | 7 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 8 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 9 | [Claude Managed Agents (Anthropic)](/compare/claude-managed-agents) | Linux container on Anthropic-managed infrastructure | Not documented | Partly | Not documented | — | 2026-10-24 | | 10 | [GitHub Codespaces](/compare/github-codespaces) | container on a VM | Not documented | Not documented | Not documented | — | 2026-10-24 | | 11 | [Ona](/compare/ona) | VM per environment | Not documented | Partly | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The sandboxes, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** running Claude Code, Codex or other agents unattended for hours, several at once. Every sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root, systemd and Docker. Claude Code, Codex, OpenCode, Pi, Kimi Code and Mistral Vibe are preinstalled and can be prompted, followed and steered over the CLI or API (`sbx prompt`, `sbx events`, `sbx steer`). Stopping saves memory, so the agent's session, `tmux` and the dev server are still running after resume; a stopped sandbox costs nothing. Gemini CLI isn't preinstalled; install it in the VM with `npm install -g @google/gemini-cli`. **Where others win:** no GPUs, no self-hosting, and the integrated agents need your own API keys (subscription sign-in for them is coming). ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** live forking of a running agent's VM. Freestyle provides full Linux VMs with pause and resume that preserves memory, live forking, SSH and HTTPS domains per port, and its base snapshots include Claude Code, Codex and OpenCode. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 3. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** an open-source sandbox with prebuilt agent templates. E2B offers Claude Code and Codex templates that you run as commands, Firecracker microVMs whose pause keeps memory and processes, and Terraform to run it in your own cloud. **Where it's weaker for this use:** sessions are capped per plan, and Docker comes from a template rather than being preinstalled. ### 4. Docker Sandboxes *At a glance (source: Docker Sandboxes docs, checked 2026-10-24): isolation: microVM (hypervisor) · resume keeps processes: Not documented · longest session: cloud: 1 hour by default · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/docker-sandboxes).* **Best for:** running agents on your own laptop for free. Docker's `sbx` CLI runs coding agents in microVM sandboxes with a private Docker daemon each, working in your local project directory, with a host-side proxy that keeps credentials out of the VM. A cloud option runs on Docker-managed infrastructure. **Where it's weaker for this use:** cloud sandboxes expire after one hour by default. ### 5. exe.dev *At a glance (source: exe.dev docs, checked 2026-10-24): isolation: VM (own VMM process per VM) · resume keeps processes: Not documented · longest session: Not documented · price: $0.21/hour · [full comparison and sources](/compare/exe-dev).* **Best for:** many persistent VMs on one flat monthly pool, or regions outside the US. exe.dev VMs are reachable over SSH as root and have HTTPS URLs by default, with nine regions. **Where it's weaker for this use:** whether a paused VM keeps its memory isn't documented. ### 6. Amp Orbs *At a glance (source: Amp Orbs docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Yes · longest session: no run limit stated; pauses when idle · price: $0.33/hour · [full comparison and sources](/compare/amp-orbs).* **Best for:** teams already using the Amp coding agent. Orbs are remote Debian machines where Amp works on a thread; pausing saves memory and disk, so terminals and dev servers are still running when it wakes, and Portals give shareable URLs for dev servers with review comments sent back to the agent. **Where it's weaker for this use:** it's built around Amp rather than any agent you choose, and the isolation type isn't documented. ### 7. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** teams that need Windows or GPU environments alongside Linux, or SDKs in Ruby, Go or Java. Daytona's VM sandboxes can pause with memory preserved. **Where it's weaker for this use:** memory-saving stop and VM isolation depend on which sandbox class you pick. ### 8. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** agents that work in bursts. Sprites are persistent microVMs with checkpoints, an always-on HTTPS URL, and no compute charge while idle. **Where it's weaker for this use:** after a longer idle period the in-memory state is dropped, so a running agent process doesn't always survive. ### 9. Claude Managed Agents *At a glance (source: Claude Managed Agents (Anthropic) docs, checked 2026-10-24): isolation: Linux container on Anthropic-managed infrastructure · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/claude-managed-agents).* **Best for:** letting Anthropic run the Claude agent loop for you. The runtime (in beta) runs Claude in per-session Linux containers with many languages, Chromium and Playwright preinstalled, built-in tools and event streaming. **Where it's weaker for this use:** it's Claude only, runs in containers rather than VMs, and you don't get a machine you keep. ### 10. GitHub Codespaces *At a glance (source: GitHub Codespaces docs, checked 2026-10-24): isolation: container on a VM · resume keeps processes: Not documented · longest session: 12 h maximum lifetime; idle timeout 30 min default, up to 240 min · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/github-codespaces).* **Best for:** work that lives in GitHub and uses `devcontainer.json`, with free monthly hours on personal accounts. **Where it's weaker for this use:** a codespace has a 12-hour maximum lifetime and runs as a container on a VM. ### 11. Ona *At a glance (source: Ona docs, checked 2026-10-24): isolation: VM per environment · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/ona).* **Best for:** enterprises that need environments inside their own AWS or GCP VPC, defined as Dev Containers with org-wide guardrails. **Where it's weaker for this use:** its docs say new customer sign-up is closed, and its site describes Ona as part of OpenAI. ## What it costs to run an agent Two costs add up: the machine and the model. The machine is the table's hourly price while the agent works; the model (Anthropic, OpenAI, Google) bills your own key for tokens. What differs most between platforms is the cost while the agent waits: - **Free while stopped or idle:** a stopped Sandboxer sandbox costs nothing; Fly.io Sprites charge no compute while idle. - **Billed while running:** most platforms bill per second or minute while the machine is up, so stop or pause what you aren't using. On Sandboxer, three agents on the `default` size (4 vCPU, 8 GB, $0.036/hour) for two hours cost about 22 cents of machine time. ## How we tested - **Other platforms:** we read each platform's own docs and pricing pages and recorded what they state, with a date; a second reviewer checked each claim against its source. We didn't run other platforms. - **Sandboxer, measured by us** on 2026-10-24 (10 runs, wall-clock from a client with a 27 ms round trip ([conditions and results](/guides/bench/README.txt)), CLI round trips included): new sandbox ready in 3.19–3.20 s; resume with memory in 1.34–1.37 s; a running process kept the same PID across stop and resume in 10 of 10 runs. We also checked that Claude Code (2.1.301), Codex (codex-cli 0.163.0) and OpenCode were installed, and that `claude --dangerously-skip-permissions` starts as the non-root user. Script: [create-resume.sh](/guides/bench/create-resume.sh). Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **Unattended Claude Code or Codex in the cloud, several in parallel, keeping sessions across pauses:** Sandboxer. - **Your team uses Amp:** Amp Orbs. - **Open source, or your own cloud:** E2B. - **Free, on your laptop:** Docker Sandboxes. - **Windows or GPUs:** Daytona. - **Anthropic should run the loop:** Claude Managed Agents. - **Your code and reviews live in GitHub:** Codespaces. Step-by-step: [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm), [Run Codex CLI on a remote machine](/guides/run-codex-cli-on-a-remote-machine) and [Run coding agents in parallel](/guides/run-coding-agents-in-parallel). ## FAQ ### Does Claude Code work in a sandbox? Yes. Claude Code runs on Linux, and for permission prompts off Anthropic's docs recommend isolated containers or VMs without internet access. A VM with nothing valuable on it, where Claude Code runs as a non-root user, fits that guidance. ### Can I run Codex and Claude Code side by side? Yes. On Sandboxer both are preinstalled in every sandbox; give each its own sandbox, or run separate conversations in one with `sbx prompt --provider codex` and `--provider claude`. ### How do I run Gemini CLI in a cloud VM? Install it in any Linux VM with `npm install -g @google/gemini-cli` and sign in. It isn't one of Sandboxer's preinstalled agents, but every sandbox has Node.js and root, so the install works as on any machine. ### Which sandbox keeps the agent running when my laptop is closed? Any cloud sandbox does while it's running. The difference is what happens on pause: Sandboxer, Amp Orbs, Freestyle and E2B document resuming with memory, so the agent's process continues instead of restarting. ### What's the cheapest way to run a coding agent in the cloud? Stop or pause the machine whenever the agent isn't working. Per-second billing plus a free stopped state (Sandboxer) or no compute charge while idle (Fly.io Sprites) keeps the machine cost small next to model tokens. ### Is it safe to give a coding agent my API keys in a sandbox? Give it only the keys the task needs, preferably dedicated ones with spend limits, and nothing else from your machine. On Sandboxer, start with `--no-env` and pass keys with `-e`. ## Related - [Top AI sandbox platforms for code execution](/guides/top-ai-sandbox-platforms-for-code-execution) - [Cloud dev environments compared](/compare/cloud-dev-environments) - [All comparisons](/compare) --- # Best Daytona sandbox alternatives in 2026, ranked Source: https://sandboxer.com/guides/best-daytona-sandbox-alternatives. Updated 2026-10-27. **Short answer:** The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others, including Daytona, are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. *This page is about Daytona the AI sandbox platform (daytona.io), not the watch.* ## What Daytona is, and why teams look elsewhere Daytona offers several sandbox classes, from containers to Linux and Windows VMs and GPU sandboxes, with SDKs for TypeScript, Python, Ruby, Go and Java and a computer-use API. Its VM sandboxes can pause with memory preserved. Our full side-by-side is at [Sandboxer vs Daytona](/compare/daytona). Common reasons to compare alternatives: - you want **VM isolation and memory-preserving stop on every sandbox**, not only on one class; - you need **nested virtualization** (`/dev/kvm`) for emulators or nested VMs; - you want **open source** or to run in **your own cloud**; - you need a **lower list price** for your size, or a different billing model. **When to stay on Daytona:** if you need Windows sandboxes, or an SDK in Ruby or Java, Daytona covers those and the alternatives below don't. For GPUs, Daytona, Modal and Northflank all qualify. ## How to compare the alternatives 1. **Isolation on every sandbox:** a microVM or VM with its own kernel. 2. **Pause with memory:** running processes survive a pause. 3. **A full machine:** root, systemd and Docker. 4. **Session length and lifecycle:** how long a sandbox may run, and what happens when it idles. 5. **Price:** the list price for a 4 vCPU, 8 GB machine-hour. 6. **Where it runs:** hosted only, your cloud, or self-hosted. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-27 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 4 | [Blaxel](/compare/blaxel) | microVM (hypervisor not named) | Yes | Not documented | Not documented | $0.33 | 2026-10-24 | | 5 | [Northflank Sandboxes](/compare/northflank-sandboxes) | microVM on Cloud Hypervisor (gVisor for GPU sandboxes) | Not documented | Yes | Not documented | $0.13 | 2026-10-24 | | 6 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 7 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 8 | [Runloop](/compare/runloop) | VM (technology not named) | Partly | Not documented | Not documented | $0.63 | 2026-10-24 | | 9 | [Modal](/compare/modal) | gVisor or VM | Partly | Partly | Partly | $0.48 | 2026-10-15 | | 10 | [Novita Agent Sandbox](/compare/novita-agent-sandbox) | Not documented | Yes | Not documented | Not documented | $0.23 | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The alternatives, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** teams switching for VM isolation and memory-saving stop on every sandbox. Each sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root, systemd, Docker and `/dev/kvm`, coding agents preinstalled, and an HTTPS URL for any port. Stopping saves memory and costs nothing while stopped. **Where Daytona or others win:** no GPUs, no Windows, no self-hosting, and SDKs in TypeScript and Python only (plus a REST API). ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** live forking with SSH and domain routing, and enterprise needs (an Enterprise SLA, SAML/SCIM). Full Linux VMs with memory-preserving pause. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 3. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** open source and running in your own cloud. Firecracker microVMs, pause with memory and fork, Apache-2.0 runtime and control plane, Terraform for AWS or GCP, and a desktop SDK for computer use. **Where it's weaker for this use:** sessions are capped per plan, and E2B's docs list nested-virtualization runtimes (Kata, KubeVirt) as unsupported. ### 4. Blaxel *At a glance (source: Blaxel docs, checked 2026-10-24): isolation: microVM (hypervisor not named) · resume keeps processes: Yes · longest session: no fixed limit stated · price: $0.33/hour · [full comparison and sources](/compare/blaxel).* **Best for:** near-instant wake from standby and regions in the US and Europe, on microVM sandboxes. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 5. Northflank Sandboxes *At a glance (source: Northflank Sandboxes docs, checked 2026-10-24): isolation: microVM on Cloud Hypervisor (gVisor for GPU sandboxes) · resume keeps processes: Not documented · longest session: no limit · price: $0.13/hour · [full comparison and sources](/compare/northflank-sandboxes).* **Best for:** bring-your-own-cloud with GPUs. Northflank documents Cloud Hypervisor microVMs (gVisor for GPU workloads), sandboxes that run until you delete them, and deployment into your own cloud. **Where it's weaker for this use:** whether a pause keeps memory isn't documented. ### 6. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** persistent boxes that cost almost nothing while idle, with checkpoints and an always-on HTTPS URL. **Where it's weaker for this use:** after a longer idle period, in-memory state is dropped. ### 7. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps on Vercel. Firecracker microVMs, active-CPU billing that doesn't charge CPU while waiting on I/O, many regions and VPC peering. **Where it's weaker for this use:** resume doesn't keep running processes. ### 8. Runloop *At a glance (source: Runloop docs, checked 2026-10-24): isolation: VM (technology not named) · resume keeps processes: Partly · longest session: Not documented · price: $0.63/hour · [full comparison and sources](/compare/runloop).* **Best for:** agent evaluation with blueprint-based environments, and VPC deployment on Enterprise. **Where it's weaker for this use:** suspend and resume need the Pro plan, the docs don't say whether memory is kept, and the VM technology isn't named. ### 9. Modal *At a glance (source: Modal docs, checked 2026-10-15): isolation: gVisor or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.48/hour · [full comparison and sources](/compare/modal).* **Best for:** very large numbers of short jobs and GPUs. Modal says it can start a million sandboxes in under a minute, and sandboxes can have GPUs, with raw TCP tunnels and SDKs for Python, JavaScript and Go. **Where it's weaker for this use:** isolation is gVisor by default rather than a VM with its own kernel. ### 10. Novita Agent Sandbox *At a glance (source: Novita Agent Sandbox docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Yes · longest session: 1 h on free tier, 24 h on paid · price: $0.23/hour · [full comparison and sources](/compare/novita-agent-sandbox).* **Best for:** pay-as-you-go billing with no plan fee, and browser-use or desktop computer-use workloads, which Novita lists as supported. **Where it's weaker for this use:** sessions are capped (1 hour on the free tier, 24 hours on paid), and the isolation type isn't stated. ## Moving off Daytona To Sandboxer, the shortest path keeps your Daytona SDK code: Sandboxer serves a Daytona-compatible API, so for the calls it covers (sandbox lifecycle, files, processes and sessions, previews) you change the API URL and key. Our [migration guide](/migrate/daytona) shows it step by step and lists what isn't covered yet. To other platforms, or for anything outside that list, plan for three changes: 1. **Create and exec calls.** Replace SDK calls for creating sandboxes, running commands and moving files with the new platform's SDK or REST API. On Sandboxer that's the [REST API](/docs/api) or the TypeScript and Python SDKs. 2. **Images and templates.** Rebuild your base image or snapshot. On Sandboxer, set one sandbox up and save it as a named snapshot (`sbx snapshot `), then start from it with `sbx new --from `. 3. **Lifecycle.** Map auto-stop, archive and delete behaviour. On Sandboxer a stop saves memory, a stopped sandbox is free, and auto-stop is configurable up to 30 days or off. ## How we tested We read each platform's own docs and pricing pages, including Daytona's, and recorded what they state, with a date; a second reviewer checked each claim against its source. We didn't benchmark other platforms. Sandboxer's own numbers, measured by us on 2026-10-24 over 10 runs from a client with a 27 ms round trip ([conditions and results](/guides/bench/README.txt)): new sandbox ready in 3.19–3.20 s, resume with memory in 1.34–1.37 s, and a running process kept its PID across stop and resume in 10 of 10 runs ([script](/guides/bench/create-resume.sh)). Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **VM isolation, memory-saving stop and Docker on every sandbox:** Sandboxer. - **Open source or your own cloud:** E2B, or Northflank for bring-your-own-cloud with GPUs. - **A million short jobs:** Modal. - **Your app is on Vercel:** Vercel Sandbox. - **Windows sandboxes or Ruby/Java SDKs:** stay on Daytona. **GPUs:** Daytona, Modal or Northflank. ## FAQ ### What is the best alternative to Daytona? It depends on why you're moving. For VM isolation and memory-preserving stop on every sandbox, we rank Sandboxer first; for open source, E2B; for GPU-heavy or massive short-job fleets, Modal or Northflank. ### Which Daytona alternatives can I run in my own cloud? E2B's runtime and control plane are Apache-2.0 with Terraform for AWS or GCP, Northflank deploys into your own cloud, and Runloop offers VPC deployment on Enterprise. Sandboxer is hosted only. ### Which Daytona alternative keeps memory when a sandbox is paused? Sandboxer, E2B, Blaxel, Freestyle and Novita document pause or resume with memory. Daytona itself does for its VM class. ### Which Daytona alternative supports GPUs? Modal and Northflank list GPUs in sandboxes, and Daytona itself offers GPU sandboxes. Sandboxer has no GPUs. ### Which alternative is cheapest? Compare the table's per-hour prices for your size, then your billing pattern: per-second billing with a free stopped state, active-CPU billing, or usage-based idle pricing can matter more than the list price. ### Does Daytona support Windows sandboxes? Yes. Daytona offers Windows VMs as one of its sandbox classes, and a computer-use API on Linux and Windows. None of the other platforms in this ranking lists Windows sandboxes in the docs we checked; if you need them, Daytona is the stronger fit. ## Related - [Sandboxer vs Daytona](/compare/daytona) - [Top AI sandbox platforms for code execution](/guides/top-ai-sandbox-platforms-for-code-execution) - [Best sandboxes for coding agents](/guides/best-sandboxes-for-coding-agents) - [Agent sandbox comparison table](/compare/agent-sandboxes) --- # Best E2B alternatives in 2026, ranked Source: https://sandboxer.com/guides/best-e2b-alternatives. Updated 2026-10-24. **Short answer:** If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others, including E2B, are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## What E2B is, and why teams compare alternatives E2B runs each sandbox in a Firecracker microVM. Its pause keeps memory and running processes, fork makes running copies in one call, and the runtime, SDKs and control plane are Apache-2.0, with Terraform to run it in your own AWS or GCP account. Our full side-by-side is at [Sandboxer vs E2B](/compare/e2b). Common reasons to look elsewhere: - **session caps:** E2B limits how long a sandbox can run per plan; - **a fuller machine:** Docker preinstalled and systemd, rather than built into a template; - **nested virtualization:** E2B's docs list nested-virtualization runtimes (Kata, KubeVirt) as unsupported; - **price or billing model** for your workload. **When to stay on E2B:** for open source and self-hosting, E2B is the strongest fit here; Northflank also runs in your own cloud. ## How to compare the alternatives 1. **Isolation:** a microVM or VM with its own kernel. 2. **Pause with memory:** running processes survive a pause. 3. **A full machine:** root, systemd and Docker. 4. **Session length:** no short platform cap. 5. **Price:** the list price for a 4 vCPU, 8 GB machine-hour. 6. **Hosted:** managed services rank above tools you run yourself, which are listed last. We apply the criteria in this order; later ones break ties. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [Blaxel](/compare/blaxel) | microVM (hypervisor not named) | Yes | Not documented | Not documented | $0.33 | 2026-10-24 | | 4 | [Northflank Sandboxes](/compare/northflank-sandboxes) | microVM on Cloud Hypervisor (gVisor for GPU sandboxes) | Not documented | Yes | Not documented | $0.13 | 2026-10-24 | | 5 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 6 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 7 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 8 | [Cloudflare Sandbox](/compare/cloudflare-sandbox) | microVM per container instance (docs: each instance has its own kernel) | No | Partly | Not documented | $0.36 | 2026-10-24 | | 9 | [Modal](/compare/modal) | gVisor or VM | Partly | Partly | Partly | $0.48 | 2026-10-15 | | 10 | [microsandbox](/compare/microsandbox) | microVM (libkrun) | Yes | Partly | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The alternatives, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** long-running agents and jobs that need a whole machine. Each sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root, systemd, Docker preinstalled and `/dev/kvm`, coding agents preinstalled, and an HTTPS URL for any port. Stopping saves memory, a stopped sandbox costs nothing, and paid plans have no session time limit. **Where E2B or others win:** E2B is open source and self-hostable; Sandboxer is hosted only, with no GPUs and no SLA during early access. ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** live forking with SSH and domain routing. Full Linux VMs with pause and resume that preserves memory, HTTPS domains per port, an Enterprise SLA and SAML/SCIM. **Where it's weaker for this use:** persistent VMs need a paid plan, and the hypervisor isn't named in its docs. ### 3. Blaxel *At a glance (source: Blaxel docs, checked 2026-10-24): isolation: microVM (hypervisor not named) · resume keeps processes: Yes · longest session: no fixed limit stated · price: $0.33/hour · [full comparison and sources](/compare/blaxel).* **Best for:** sandboxes that wake almost instantly from standby, on microVMs, with regions in the US and Europe. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 4. Northflank Sandboxes *At a glance (source: Northflank Sandboxes docs, checked 2026-10-24): isolation: microVM on Cloud Hypervisor (gVisor for GPU sandboxes) · resume keeps processes: Not documented · longest session: no limit · price: $0.13/hour · [full comparison and sources](/compare/northflank-sandboxes).* **Best for:** bring-your-own-cloud with GPUs. Cloud Hypervisor microVMs (gVisor for GPU workloads), sandboxes that run until you delete them, and deployment into your own cloud. **Where it's weaker for this use:** whether a pause keeps memory isn't documented. ### 5. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** Windows sandboxes, GPUs, and SDKs in Ruby and Java. VM sandboxes can pause with memory preserved. **Where it's weaker for this use:** VM isolation and memory-saving stop depend on the sandbox class you pick. ### 6. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** persistent microVMs that cost almost nothing while idle, with checkpoints and an always-on HTTPS URL. **Where it's weaker for this use:** after a longer idle period the in-memory state is dropped. ### 7. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps on Vercel: Firecracker microVMs, active-CPU billing, many regions and VPC peering. **Where it's weaker for this use:** resume doesn't keep running processes. ### 8. Cloudflare Sandbox *At a glance (source: Cloudflare Sandbox docs, checked 2026-10-24): isolation: microVM per container instance (docs: each instance has its own kernel) · resume keeps processes: No · longest session: no fixed limit stated · price: $0.36/hour · [full comparison and sources](/compare/cloudflare-sandbox).* **Best for:** apps on Cloudflare Workers that want short, event-driven sandboxes, with each container instance in its own microVM. **Where it's weaker for this use:** sandboxes don't resume with memory. ### 9. Modal *At a glance (source: Modal docs, checked 2026-10-15): isolation: gVisor or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.48/hour · [full comparison and sources](/compare/modal).* **Best for:** massive numbers of short jobs and GPUs. **Where it's weaker for this use:** the default isolation is gVisor rather than a VM with its own kernel. ### 10. microsandbox *At a glance (source: microsandbox docs, checked 2026-10-24): isolation: microVM (libkrun) · resume keeps processes: Yes · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/microsandbox).* **Best for:** the closest match to E2B's self-hosting appeal: open source, libkrun microVMs on your own machines, with memory-preserving snapshots under your control. **Where it's weaker for this use:** it's beta software, its hosted cloud isn't priced yet, and locally you run and operate it yourself. ## Moving off E2B 1. **Create, exec and files.** Swap SDK calls for the new platform's SDK or REST API. On Sandboxer that's the [REST API](/docs/api) or the TypeScript and Python SDKs. 2. **Templates.** Rebuild your template once, then save it. On Sandboxer: set up one sandbox and run `sbx snapshot `, then `sbx new --from `. 3. **Lifecycle.** Map pause and timeout settings. On Sandboxer a stop saves memory, a stopped sandbox is free, and auto-stop is up to 30 days or off. ## How we tested We read each platform's own docs and pricing pages, including E2B's, recorded what they state with a date, and had a second reviewer check each claim against its source. We didn't benchmark other platforms. Sandboxer's own numbers, measured by us on 2026-10-24 over 10 runs from a client with a 27 ms round trip ([conditions and results](/guides/bench/README.txt)): ready in 3.19–3.20 s, resume with memory in 1.34–1.37 s, process kept its PID in 10 of 10 runs ([script](/guides/bench/create-resume.sh)). Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **Long sessions, a full machine with Docker, memory kept across pauses:** Sandboxer, or Freestyle. - **Windows or Ruby/Java SDKs:** Daytona. **GPUs:** Daytona, Modal or Northflank. - **Your own cloud:** Northflank. **Open source and self-hosted:** stay on E2B, or microsandbox on your own machines. - **Apps on Vercel or Cloudflare:** Vercel Sandbox or Cloudflare Sandbox. ## FAQ ### What is the best alternative to E2B? For longer sessions and a full Linux machine with memory-preserving stop, we rank Sandboxer first and Freestyle second. If you chose E2B for open source or self-hosting, staying on E2B (or microsandbox for local use) is the better fit. ### Is there an open-source alternative to E2B? microsandbox is open source and runs microVMs on your own machines. E2B itself is open source (Apache-2.0) with Terraform for AWS or GCP. ### Which E2B alternatives keep memory when paused? Sandboxer, Freestyle, Blaxel and microsandbox document pause or snapshots with memory. Daytona does for its VM class. ### Which E2B alternatives support nested virtualization? Sandboxer exposes `/dev/kvm` in every sandbox, so the Android emulator, QEMU and Firecracker run inside. Daytona offers `/dev/kvm` in Linux VM sandboxes created with the kvm option; Modal offers nested virtualization on Team and Enterprise plans, on request; E2B's docs list nested-virtualization runtimes as unsupported. ### Is E2B or Sandboxer cheaper? Compare the list prices for your machine size on [Sandboxer vs E2B](/compare/e2b), then your usage pattern: Sandboxer bills per second and charges nothing while stopped. ## Related - [Sandboxer vs E2B](/compare/e2b) - [Best Daytona sandbox alternatives](/guides/best-daytona-sandbox-alternatives) - [Top AI sandbox platforms for code execution](/guides/top-ai-sandbox-platforms-for-code-execution) --- # Best Modal Sandboxes alternatives in 2026, ranked (for sandboxes, not GPUs) Source: https://sandboxer.com/guides/best-modal-sandbox-alternatives. Updated 2026-10-24. **Short answer:** For sandbox use rather than GPU work, we rank Sandboxer first, then Freestyle and E2B: each gives a sandbox its own VM kernel instead of Modal's default gVisor, and keeps memory across pauses. Pick Northflank for GPUs in your own cloud, Vercel Sandbox for apps on Vercel, and stay on Modal for GPUs or very large numbers of short jobs. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others, including Modal, are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## What Modal Sandboxes are, and when to look elsewhere Modal's sandboxes run on its serverless platform: gVisor isolation by default, GPUs available, raw TCP tunnels as well as HTTPS ports, and SDKs for Python, JavaScript and Go. Modal says it can start a million sandboxes in under a minute. Our full side-by-side is at [Sandboxer vs Modal](/compare/modal). This page is for teams using Modal Sandboxes **as sandboxes**: agent workspaces, code execution, preview environments. Reasons to compare: - you want **a VM with its own kernel** on every sandbox rather than gVisor; - you need **running processes to survive a pause**; - you want **a full machine**: root, systemd and Docker; - you want sandboxes that live **for hours or days**. **When to stay on Modal:** for GPUs in sandboxes, or very large numbers of short jobs on the platform you already use. ## How to compare the alternatives 1. **Isolation:** a microVM or VM with its own kernel. 2. **Pause with memory:** running processes survive a pause. 3. **A full machine:** root, systemd and Docker. 4. **Session length:** no short platform cap. 5. **Price:** the list price for a 4 vCPU, 8 GB machine-hour. We apply the criteria in this order; later ones break ties. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 4 | [Blaxel](/compare/blaxel) | microVM (hypervisor not named) | Yes | Not documented | Not documented | $0.33 | 2026-10-24 | | 5 | [Northflank Sandboxes](/compare/northflank-sandboxes) | microVM on Cloud Hypervisor (gVisor for GPU sandboxes) | Not documented | Yes | Not documented | $0.13 | 2026-10-24 | | 6 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 7 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 8 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 9 | [Cloudflare Sandbox](/compare/cloudflare-sandbox) | microVM per container instance (docs: each instance has its own kernel) | No | Partly | Not documented | $0.36 | 2026-10-24 | | 10 | [Runloop](/compare/runloop) | VM (technology not named) | Partly | Not documented | Not documented | $0.63 | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The alternatives, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** agent workspaces and long jobs on a whole machine. Each sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root, systemd, Docker and `/dev/kvm`, coding agents preinstalled, and an HTTPS URL for any port. Stopping saves memory, and a stopped sandbox costs nothing. **Where Modal or others win:** no GPUs and no SLA during early access; Modal also starts far larger numbers of short sandboxes. ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** live forking with SSH and domain routing; full Linux VMs with pause and resume that preserves memory; an Enterprise SLA and SAML/SCIM. **Where it's weaker for this use:** persistent VMs need a paid plan, and the hypervisor isn't named in its docs. ### 3. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** open source and running in your own cloud. Firecracker microVMs whose pause keeps memory and processes, fork in one call, and Terraform for AWS or GCP. **Where it's weaker for this use:** sessions are capped per plan. ### 4. Blaxel *At a glance (source: Blaxel docs, checked 2026-10-24): isolation: microVM (hypervisor not named) · resume keeps processes: Yes · longest session: no fixed limit stated · price: $0.33/hour · [full comparison and sources](/compare/blaxel).* **Best for:** near-instant wake from standby on microVMs, with regions in the US and Europe. **Where it's weaker for this use:** the hypervisor isn't named in its docs. ### 5. Northflank Sandboxes *At a glance (source: Northflank Sandboxes docs, checked 2026-10-24): isolation: microVM on Cloud Hypervisor (gVisor for GPU sandboxes) · resume keeps processes: Not documented · longest session: no limit · price: $0.13/hour · [full comparison and sources](/compare/northflank-sandboxes).* **Best for:** the closest fit if you need GPUs too: Cloud Hypervisor microVMs (gVisor for GPU workloads), sandboxes that run until you delete them, and bring-your-own-cloud. **Where it's weaker for this use:** whether a pause keeps memory isn't documented. ### 6. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** persistent microVMs with no compute charge while idle. **Where it's weaker for this use:** after a longer idle period the in-memory state is dropped. ### 7. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** Windows sandboxes, GPUs, and SDKs in Ruby and Java. VM sandboxes can pause with memory preserved. **Where it's weaker for this use:** VM isolation and memory-saving stop depend on the sandbox class. ### 8. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps on Vercel: Firecracker microVMs with active-CPU billing and many regions. **Where it's weaker for this use:** resume doesn't keep running processes. ### 9. Cloudflare Sandbox *At a glance (source: Cloudflare Sandbox docs, checked 2026-10-24): isolation: microVM per container instance (docs: each instance has its own kernel) · resume keeps processes: No · longest session: no fixed limit stated · price: $0.36/hour · [full comparison and sources](/compare/cloudflare-sandbox).* **Best for:** short, event-driven sandboxes from Cloudflare Workers, each container instance in its own microVM. **Where it's weaker for this use:** no resume with memory. ### 10. Runloop *At a glance (source: Runloop docs, checked 2026-10-24): isolation: VM (technology not named) · resume keeps processes: Partly · longest session: Not documented · price: $0.63/hour · [full comparison and sources](/compare/runloop).* **Best for:** agent evaluation with blueprint-based environments, and VPC deployment on Enterprise. **Where it's weaker for this use:** suspend and resume need the Pro plan, the docs don't say whether memory is kept, and the VM technology isn't named. ## How we tested We read each platform's own docs and pricing pages, including Modal's, recorded what they state with a date, and had a second reviewer check each claim against its source. We didn't benchmark other platforms. Sandboxer's own numbers, measured by us on 2026-10-24 over 10 runs from a client with a 27 ms round trip ([conditions and results](/guides/bench/README.txt)): ready in 3.19–3.20 s, resume with memory in 1.34–1.37 s, process kept its PID in 10 of 10 runs ([script](/guides/bench/create-resume.sh)). Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **Agent workspaces on a full VM that keeps state across pauses:** Sandboxer, or Freestyle. - **Open source or your own cloud:** E2B; with GPUs, Northflank. - **Windows:** Daytona. - **Apps on Vercel or Cloudflare:** Vercel Sandbox or Cloudflare Sandbox. - **GPUs, or very large numbers of short jobs:** stay on Modal. ## FAQ ### What is the best alternative to Modal Sandboxes? For sandbox use (agent workspaces, code execution, previews), we rank Sandboxer first, then Freestyle and E2B. For GPU-heavy or very high-volume short jobs, Modal remains a strong fit. ### Does Modal use gVisor or microVMs? Modal uses gVisor by default and offers an optional VM runtime with its own kernel. Sandboxer, E2B, Vercel Sandbox, Blaxel, Cloudflare Sandbox, Fly.io Sprites and Northflank (gVisor for GPU sandboxes) document microVM isolation. ### Which Modal alternative supports GPUs? Northflank and Daytona list GPU sandboxes. Sandboxer has no GPUs. ### Which alternatives keep memory when a sandbox is paused? Sandboxer, Freestyle, E2B and Blaxel document pause or resume with memory. Daytona does for its VM class. ### Can I run Docker inside these sandboxes? On Sandboxer, Docker is preinstalled and runs inside the sandbox's own VM. For the others, the comparison table shows what each platform's docs say. ## Related - [Sandboxer vs Modal](/compare/modal) - [Best E2B alternatives](/guides/best-e2b-alternatives) - [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code) --- # Best sandboxes for browser and computer-use agents in 2026, ranked Source: https://sandboxer.com/guides/best-sandboxes-for-browser-and-computer-use-agents. Updated 2026-10-24. **Short answer:** For agents that must drive a whole desktop, we rank Sandboxer first: a Linux VM with an XFCE desktop and Chrome, computer-use tools for its built-in agents, and a live stream you can watch. Pick E2B or Daytona for desktop SDKs (Daytona and Orgo for Windows), and Browserbase, Kernel or Anchor when the agent only needs a browser. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## Two kinds of tools - **Desktop sandboxes:** a whole computer with a screen, mouse and keyboard. The agent can use any app, not just websites: an Electron app, a terminal, a file manager, a browser. - **Cloud browser services:** a hosted browser built for web automation, usually with session replay, proxies, managed logins and many concurrent sessions at a low per-hour price. There's no general-purpose machine behind it. If your agent only browses websites, a browser service is usually the better and cheaper fit. If it needs to run software, install things, or move between apps, you need a desktop sandbox. ## How to compare them 1. **A full desktop:** a graphical session with a real browser, and room for other apps. 2. **Computer-use tools:** screenshots, mouse and keyboard actions the agent can call. 3. **Watch and take over:** a live view a person can open. 4. **Session survives a pause:** the signed-in browser and open apps are still there after a pause. 5. **Session length:** no short cap. 6. **Price** you can predict. We apply the criteria in this order; later ones break ties. Browser-only services rank after desktop sandboxes on criterion 1, even though they're the better choice for pure web automation. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 3 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 4 | [Orgo](/compare/orgo) | Not documented | Partly | Not documented | Not documented | — | 2026-10-24 | | 5 | [Coasty](/compare/coasty) | Coasty-hosted VM (technology not stated) | Partly | Not documented | Not documented | — | 2026-10-24 | | 6 | [Novita Agent Sandbox](/compare/novita-agent-sandbox) | Not documented | Yes | Not documented | Not documented | $0.23 | 2026-10-24 | | 7 | [Kernel (onkernel)](/compare/kernel) | browser instance | Partly | No | No | — | 2026-10-24 | | 8 | [Browserbase](/compare/browserbase) | browser instance | Not documented | No | No | — | 2026-10-24 | | 9 | [Anchor Browser](/compare/anchor-browser) | browser instance | Not documented | No | No | — | 2026-10-24 | | 10 | [Browser Use (cloud)](/compare/browser-use-cloud) | browser instance | Not documented | No | Not documented | — | 2026-10-24 | | 11 | [Steel](/compare/steel) | browser instance | Not documented | No | No | — | 2026-10-24 | | 12 | [Hyperbrowser](/compare/hyperbrowser) | browser instance | Not documented | No | No | — | 2026-10-24 | The table compares machine features. For browser services most of them don't apply, so read their entries for what they do well. "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it (browser services price per browser hour or by credits). ## The tools, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** computer-use agents that need a whole machine. Every running sandbox has an XFCE desktop with Google Chrome at 1920×1080 that you open in a browser tab (`sbx desktop`), or stream only Chrome (`sbx browser`) with separate signed-in profiles. Its built-in agents get `computer` tools (screenshot, move, click, type, scroll) that act on that screen, and you can record it. Stopping saves memory, so the signed-in browser and open apps come back on resume. **Where others win:** the desktop streams over VNC today (WebRTC is coming); there are no Windows desktops; and for pure web automation, browser services offer session replay, proxies and much cheaper browser hours. ### 2. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** building your own computer-use agent with an open-source desktop SDK, with VNC streaming, on Firecracker microVMs whose pause keeps memory. **Where it's weaker for this use:** sessions are capped per plan. ### 3. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** computer use on Linux and Windows through Daytona's computer-use API, with SDKs in several languages. **Where it's weaker for this use:** memory-saving stop depends on the sandbox class. ### 4. Orgo *At a glance (source: Orgo docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Partly · longest session: no limit stated · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/orgo).* **Best for:** desktops built for agents: Linux and Windows computers with a browser, reachable over HTTP and VNC, driven by API, CLI, SDKs or a hosted MCP server. **Where it's weaker for this use:** its docs don't state the isolation type, and memory-preserving pause is only partly documented. ### 5. Coasty *At a glance (source: Coasty docs, checked 2026-10-24): isolation: Coasty-hosted VM (technology not stated) · resume keeps processes: Partly · longest session: TTL 5 min to 7 days, or none · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/coasty).* **Best for:** a computer-use agent as an API: give it a goal and it works on managed Linux or Windows machines, or on your own enrolled device. **Where it's weaker for this use:** the VM technology isn't named, and memory-preserving pause is only partly documented. ### 6. Novita Agent Sandbox *At a glance (source: Novita Agent Sandbox docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Yes · longest session: 1 h on free tier, 24 h on paid · price: $0.23/hour · [full comparison and sources](/compare/novita-agent-sandbox).* **Best for:** pay-as-you-go sandboxes where browser use and desktop computer use (with VNC viewing) are listed as supported workloads, with pause that keeps memory. **Where it's weaker for this use:** its docs list computer use as a workload but don't describe a desktop or computer-use API, and sessions are capped (1 hour on the free tier, 24 hours on paid). ### 7. Kernel *At a glance (source: Kernel (onkernel) docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Partly · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/kernel).* **Best for:** browsers billed per second, with standby between actions and no charge for idle browsers in pools, plus headful and GPU-accelerated options. **Where it's weaker for this use:** browser only. ### 8. Browserbase *At a glance (source: Browserbase docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Not documented · longest session: 15 min on Free; other plans not listed · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/browserbase).* **Best for:** web agents. The Stagehand SDK (act, extract, observe), session logs, live view and replay of each step, and regions in the US, EU and UK. **Where it's weaker for this use:** it's a browser, not a desktop, and the free plan caps sessions at 15 minutes. ### 9. Anchor Browser *At a glance (source: Anchor Browser docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/anchor-browser).* **Best for:** agents that log into sites: managed authentication including MFA identities, persistent profiles and volumes, and natural-language tasks. **Where it's weaker for this use:** browser only. ### 10. Browser Use (cloud) *At a glance (source: Browser Use (cloud) docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/browser-use-cloud).* **Best for:** the lowest listed browser-hour rate, pay-as-you-go credits that don't expire, and concurrency from 10 up to 1,000 sessions. **Where it's weaker for this use:** browser only. ### 11. Steel *At a glance (source: Steel docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Not documented · longest session: 15 min Launch, 1 h Scale, 24 h Enterprise · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/steel).* **Best for:** an open-source browser API you can self-host, or cheap hosted browser hours with free starter credit. **Where it's weaker for this use:** browser only, and hosted sessions are capped by plan. ### 12. Hyperbrowser *At a glance (source: Hyperbrowser docs, checked 2026-10-24): isolation: browser instance · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/hyperbrowser).* **Best for:** many cheap concurrent browser sessions on a low-cost monthly plan, with a free tier. **Where it's weaker for this use:** browser only, billed in credits. ## What it costs Desktop sandboxes bill like machines: Sandboxer's `default` size (4 vCPU, 8 GB) is $0.036/hour, billed per second, and nothing while stopped. Browser services bill per browser hour or by credits, often much lower per session-hour, plus proxy bandwidth where used. For pure web automation at scale, a browser service usually costs less; for agents that need a whole desktop, compare machine-hours. ## How we tested We read each platform's own docs and pricing pages, recorded what they state with a date, and had a second reviewer check each claim against its source. We didn't benchmark other platforms. Sandboxer's desktop features are from our [desktop docs](/docs/desktop); our create and resume timings, measured by us on 2026-10-24, are in [results and conditions](/guides/bench/README.txt). ## Which should you choose? - **The agent needs a whole Linux desktop and to keep its session across pauses:** Sandboxer. - **You're building your own computer-use agent on an open-source SDK:** E2B. - **Windows desktops:** Daytona, Orgo or Coasty. - **A computer-use agent as a service:** Coasty. - **Web automation only:** Browserbase (replay and Stagehand), Kernel (per-second standby), Anchor (logins and MFA), Steel (self-host), Browser Use (lowest browser-hour rate), Hyperbrowser (cheap monthly concurrency). ## FAQ ### What is a computer-use agent? An AI agent that operates a computer the way a person does: it takes screenshots, then moves the mouse, clicks and types. It needs a screen to work on, which is what a desktop sandbox provides. ### Do I need a full desktop or just a cloud browser? If the agent only uses websites, a cloud browser is enough and usually cheaper. If it needs other apps, files, a terminal or installs, use a desktop sandbox. ### Which sandboxes offer Windows desktops? Daytona, Orgo and Coasty list Windows. Sandboxer's desktops are Linux only. ### Can I watch what the agent does? Yes on most of these. Sandboxer streams the desktop to a browser tab and can record the screen; E2B, Daytona and Orgo offer VNC; Browserbase has live view and session replay. ### Does the agent stay signed in between sessions? On Sandboxer, stopping saves memory, so the browser and its sessions are still open after resume, and `sbx browser --profile` keeps separate Chrome profiles. Anchor Browser offers persistent profiles and managed logins. ## Related - [Desktop docs](/docs/desktop) - [Computer-use use case](/use-cases/computer-use) - [Cloud browsers compared](/compare/cloud-browsers) - [Top AI sandbox platforms](/guides/top-ai-sandbox-platforms-for-code-execution) --- # Best ways to run untrusted or LLM-generated code safely in 2026, ranked Source: https://sandboxer.com/guides/best-ways-to-run-untrusted-code-safely. Updated 2026-10-24. **Short answer:** Run it in a disposable microVM that holds none of your secrets, then throw it away. We rank Sandboxer first for full Linux microVMs with a no-secrets mode, then E2B, Vercel Sandbox and Cloudflare Sandbox. Use OpenAI's Code Interpreter for quick analysis inside the OpenAI API, and microsandbox or Firecracker to run microVMs yourself. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## The rules that matter more than the tool Whatever you pick, four rules do most of the work: 1. **A strong boundary.** Give the code its own kernel (a VM or microVM), not a container sharing yours. See [Container vs microVM](/guides/container-vs-microvm-for-untrusted-code). 2. **Nothing valuable inside.** No SSH keys, cloud credentials or production access. Pass only short-lived, narrowly scoped keys the job needs. 3. **Limit where it can connect.** Code that has read a secret can send it out. Filter egress, or assume anything inside can leave. 4. **Disposable by default.** Start fresh, delete afterwards, and review output before it touches anything real. ## How to compare the options 1. **Isolation:** a microVM or VM with its own kernel. 2. **Enough machine for the code:** a full Linux environment if the code installs packages or runs services; a lighter runtime may be enough for short scripts. 3. **Session length:** long enough for the job. 4. **Price:** a published price for the size you need. We apply criteria 1–4 in this order; later ones break ties. Hosted services are ranked first; tools you run yourself (microsandbox, self-hosted Firecracker) are listed after them. Docker Sandboxes ranks with the hosted services because it also runs in Docker's cloud; its "Docker / full Linux" cell reflects Docker support only, since root isn't documented, so we count it as partly meeting criterion 2. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 3 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 4 | [Cloudflare Sandbox](/compare/cloudflare-sandbox) | microVM per container instance (docs: each instance has its own kernel) | No | Partly | Not documented | $0.36 | 2026-10-24 | | 5 | [Docker Sandboxes](/compare/docker-sandboxes) | microVM (hypervisor) | Not documented | Yes | Not documented | — | 2026-10-24 | | 6 | [Deno Sandbox](/compare/deno-sandbox) | Linux microVM | Not documented | Not documented | Not documented | — | 2026-10-24 | | 7 | [OpenAI Containers (Code Interpreter)](/compare/openai-containers) | Sandboxed virtual machine (per OpenAI docs) | No | No | Not documented | — | 2026-10-24 | | 8 | [Modal](/compare/modal) | gVisor or VM | Partly | Partly | Partly | $0.48 | 2026-10-15 | | 9 | [microsandbox](/compare/microsandbox) | microVM (libkrun) | Yes | Partly | Not documented | — | 2026-10-24 | | 10 | [Self-hosted Firecracker](/compare/self-hosted-firecracker) | Firecracker microVM (KVM) | Yes | Partly | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The options, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** untrusted code that needs a whole machine, such as agent output, user submissions or packages you don't know. Each sandbox is a Cloud Hypervisor microVM with its own kernel. A `--no-env` sandbox receives none of your variables, files or credentials and can't act on your account. Sandboxes can't reach each other or our internal networks. ```bash sbx new --no-env # no secrets of yours inside sbx scp ./untrusted.py sb_k3v9x2mq:/home/user/ sbx exec sb_k3v9x2mq --timeout 300 "python3 untrusted.py" sbx delete sb_k3v9x2mq --yes ``` **Where others win:** outbound internet is open by default, so filter egress yourself if you need to; no GPUs. ### 2. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** code execution from a TypeScript or Python SDK on Firecracker microVMs, with the option to self-host the open-source stack. **Where it's weaker for this use:** sessions are capped per plan. ### 3. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps on Vercel that need isolated code execution: Firecracker microVMs, a firewall, and Secure Compute with static IPs and VPC peering. **Where it's weaker for this use:** it's part of the Vercel platform and bills active CPU. ### 4. Cloudflare Sandbox *At a glance (source: Cloudflare Sandbox docs, checked 2026-10-24): isolation: microVM per container instance (docs: each instance has its own kernel) · resume keeps processes: No · longest session: no fixed limit stated · price: $0.36/hour · [full comparison and sources](/compare/cloudflare-sandbox).* **Best for:** short, event-driven execution from Cloudflare Workers; each container instance runs in its own microVM, billed in 10 ms steps of active time. **Where it's weaker for this use:** the environment is a container controlled from Workers rather than a general-purpose machine. ### 5. Docker Sandboxes *At a glance (source: Docker Sandboxes docs, checked 2026-10-24): isolation: microVM (hypervisor) · resume keeps processes: Not documented · longest session: cloud: 1 hour by default · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/docker-sandboxes).* **Best for:** microVM sandboxes that run free on your own machine or in Docker's cloud, with a host-side network proxy that keeps credentials out of the VM. **Where it's weaker for this use:** cloud sandboxes expire after one hour by default. ### 6. Deno Sandbox *At a glance (source: Deno Sandbox docs, checked 2026-10-24): isolation: Linux microVM · resume keeps processes: Not documented · longest session: 30 minutes per session; extendable · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/deno-sandbox).* **Best for:** short code-execution jobs from JavaScript or Python, in Linux microVMs that Deno says start in under 200 ms. **Where it's weaker for this use:** sessions are 30 minutes (extendable). ### 7. OpenAI Containers (Code Interpreter) *At a glance (source: OpenAI Containers (Code Interpreter) docs, checked 2026-10-24): isolation: Sandboxed virtual machine (per OpenAI docs) · resume keeps processes: No · longest session: expires after 20 minutes without use · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/openai-containers).* **Best for:** agents already on the OpenAI API that need quick data analysis or code runs, with no infrastructure of your own. OpenAI describes each container as a sandboxed virtual machine, with Python, Node.js and other runtimes preinstalled. **Where it's weaker for this use:** a container expires after 20 minutes without activity, and there's no root. ### 8. Modal *At a glance (source: Modal docs, checked 2026-10-15): isolation: gVisor or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.48/hour · [full comparison and sources](/compare/modal).* **Best for:** very large numbers of short executions, and GPUs. **Where it's weaker for this use:** the default isolation is gVisor rather than a VM with its own kernel. ### 9. microsandbox *At a glance (source: microsandbox docs, checked 2026-10-24): isolation: microVM (libkrun) · resume keeps processes: Yes · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/microsandbox).* **Best for:** running microVMs inside your own app or on your own machines: open source, libkrun microVMs. **Where it's weaker for this use:** it's beta software, and you run and operate it yourself. ### 10. Self-hosted Firecracker *At a glance (source: Self-hosted Firecracker docs, checked 2026-10-24): isolation: Firecracker microVM (KVM) · resume keeps processes: Yes · longest session: no limit · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/self-hosted-firecracker).* **Best for:** full control on your own hardware. Firecracker's docs describe workloads as "considered sacred … and malicious" and require the `jailer` in production. **Where it's weaker for this use:** you build networking, egress filtering ("should be filtered at the host-level", per its docs), images and an API yourself. ## What it costs For short executions, per-second or per-active-time billing keeps costs small. Sandboxer's `default` size is $0.036/hour, billed per second, so a 30-second run costs a fraction of a cent. Platforms built for short jobs, such as Deno, Cloudflare and OpenAI's per-session containers, often suit very short, frequent runs; a full VM suits jobs that need installs, services or long runtimes. ## How we tested We read each platform's own docs and pricing pages, recorded what they state with a date, and had a second reviewer check each claim against its source. Claims about Docker, gVisor and Firecracker come from those projects' own docs. Sandboxer's isolation and secrets behaviour are in our [security docs](/docs/security) and [environment docs](/docs/environments); our timings, measured by us on 2026-10-24, are in [results and conditions](/guides/bench/README.txt). ## Which should you choose? - **Untrusted code that needs a whole machine, with none of your secrets:** Sandboxer. - **SDK-first code execution, or self-hosting:** E2B. - **Inside Vercel or Cloudflare:** Vercel Sandbox or Cloudflare Sandbox. - **Short JS or Python snippets:** Deno Sandbox. - **Inside the OpenAI API:** Code Interpreter containers. - **On your own hardware:** microsandbox or self-hosted Firecracker. ## FAQ ### What is the safest way to run code an LLM wrote? In a disposable VM or microVM that holds none of your secrets, with outbound traffic limited where you can, deleted after the run. Review the output before using it anywhere real. ### Is Docker safe for running untrusted code? A plain container shares the host's kernel, and Docker's own docs say the daemon needs root unless you use rootless mode. For untrusted code, a microVM is a stronger boundary. ### Do I need a VM if I use gVisor? gVisor adds a strong layer of isolation by intercepting system calls in a user-space kernel with a Linux-like interface. A microVM runs a real Linux kernel of its own behind hardware virtualization. ### How do I stop the code from stealing my API keys? Don't put them there. Start the sandbox with no secrets (`sbx new --no-env` on Sandboxer) and pass only a short-lived key for that one job, or use a proxy that injects credentials outside the sandbox (Docker Sandboxes documents one). ### Can sandboxes reach each other? On Sandboxer, no: VM-to-VM traffic is blocked, and sandboxes can't reach our hosts or internal networks. Connect them over the internet with your own authentication if you need to. ## Related - [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code) - [What is an AI agent sandbox?](/guides/what-is-an-ai-agent-sandbox) - [Security docs](/docs/security) --- # Best cloud dev environments for AI agents in 2026, ranked (vs Codespaces, Ona and others) Source: https://sandboxer.com/guides/best-cloud-dev-environments-for-ai-agents. Updated 2026-10-24. **Short answer:** For coding agents we rank Sandboxer first: a full Ubuntu microVM per agent with root, Docker and agents preinstalled, which keeps memory across a stop. Prized and exe.dev also give agents full Linux boxes. Pick Codespaces if your work lives in GitHub, Namespace for macOS, Lightning AI for GPUs, and Ona for environments inside your own VPC. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## Dev environments for people vs for agents Cloud dev environments started as remote workspaces for developers: open an IDE in the browser, code, close the tab. Coding agents need something slightly different: - they run **unattended for hours**, so session caps and idle timeouts matter; - they need **root and Docker** to install and run whatever the task needs; - you want **many in parallel**, each isolated from the others; - and **pausing without losing the session**, so you can stop paying while the agent waits for you. ## How to compare them 1. **A full machine:** root and Docker. 2. **Isolation:** its own VM or microVM. 3. **State across sleep or stop:** whether running processes survive. 4. **Session length:** no short cap for unattended runs. 5. **Preview URLs:** a URL for what the agent builds. 6. **Self-serve:** you can sign up and start today. We apply the criteria in this order; later ones break ties. Platforms not open to new customers are listed last. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [Prized](/compare/prized) | Firecracker microVM (sandboxes); dedicated VM (boxes) | Partly | Yes | No | $0.26 | 2026-10-24 | | 3 | [exe.dev](/compare/exe-dev) | VM (own VMM process per VM) | Not documented | Yes | Not documented | $0.21 | 2026-10-24 | | 4 | [Namespace Devboxes](/compare/namespace-devboxes) | full VM | Not documented | Yes | Not documented | — | 2026-10-24 | | 5 | [Amp Orbs](/compare/amp-orbs) | Not documented | Yes | Partly | Not documented | $0.33 | 2026-10-24 | | 6 | [Lightning AI Studios](/compare/lightning-ai-studios) | Not documented | Partly | Partly | Not documented | — | 2026-10-24 | | 7 | [boxes.dev](/compare/boxes-dev) | separate cloud machine per agent | Partly | Not documented | Not documented | $0.60 | 2026-10-24 | | 8 | [GitHub Codespaces](/compare/github-codespaces) | container on a VM | Not documented | Not documented | Not documented | — | 2026-10-24 | | 9 | [Zo Computer](/compare/zo-computer) | Not documented | Partly | Not documented | Not documented | — | 2026-10-24 | | 10 | [Cube Computer](/compare/cube-computer) | Not documented | No | Not documented | Not documented | $0.036 | 2026-10-24 | | 11 | [Sealos DevBox](/compare/sealos-devbox) | Kubernetes-based (isolation not stated) | Not documented | Not documented | Not documented | — | 2026-10-24 | | 12 | [Ona](/compare/ona) | VM per environment | Not documented | Partly | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The environments, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** agents that run unattended, many in parallel. Every sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root, systemd and Docker, with Claude Code, Codex and other agents preinstalled and driven over the CLI or API. Stopping saves memory, any port becomes an HTTPS URL, and you can SSH in or open a desktop. **Where others win:** no macOS or Windows environments, no GPUs, and no browser IDE built in (use SSH, for example VS Code Remote-SSH). ### 2. Prized *At a glance (source: Prized docs, checked 2026-10-24): isolation: Firecracker microVM (sandboxes); dedicated VM (boxes) · resume keeps processes: Partly · longest session: free tier: 60 minutes per sandbox · price: $0.26/hour · [full comparison and sources](/compare/prized).* **Best for:** a dedicated cloud Linux box that syncs with your laptop, plus short-lived Firecracker sandboxes with live-process forks. **Where it's weaker for this use:** the sandboxes are in beta, and nested KVM isn't available inside a box. ### 3. exe.dev *At a glance (source: exe.dev docs, checked 2026-10-24): isolation: VM (own VMM process per VM) · resume keeps processes: Not documented · longest session: Not documented · price: $0.21/hour · [full comparison and sources](/compare/exe-dev).* **Best for:** many persistent VMs on one flat monthly pool, reachable as root over SSH with HTTPS URLs, in nine regions (one closed to new accounts). **Where it's weaker for this use:** whether a paused VM keeps its memory isn't documented. ### 4. Namespace Devboxes *At a glance (source: Namespace Devboxes docs, checked 2026-10-24): isolation: full VM · resume keeps processes: Not documented · longest session: 3 h (Developer, Devbox Plus), 5 h (Team), 24 h (Business) · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/namespace-devboxes).* **Best for:** macOS devboxes on Apple Silicon, or teams already on Namespace for CI. Full VMs with Docker, persistent storage, SSH and shareable authenticated URLs. **Where it's weaker for this use:** sessions are capped at 3 hours on the Developer and Devbox Plus plans. ### 5. Amp Orbs *At a glance (source: Amp Orbs docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Yes · longest session: no run limit stated; pauses when idle · price: $0.33/hour · [full comparison and sources](/compare/amp-orbs).* **Best for:** teams using the Amp agent: orbs pause with memory and disk, and Portals share dev-server URLs with review comments back to the agent. **Where it's weaker for this use:** it's built around Amp, and the isolation type isn't documented. ### 6. Lightning AI Studios *At a glance (source: Lightning AI Studios docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Partly · longest session: free: restart every 4 h; paid: none listed · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/lightning-ai-studios).* **Best for:** AI and ML work that needs GPUs, from T4 to H200, in persistent workspaces you can duplicate, with a free CPU workspace. **Where it's weaker for this use:** free workspaces restart every 4 hours, and Docker is only partly documented. ### 7. boxes.dev *At a glance (source: boxes.dev docs, checked 2026-10-24): isolation: separate cloud machine per agent · resume keeps processes: Partly · longest session: Not documented · price: $0.60/hour · [full comparison and sources](/compare/boxes-dev).* **Best for:** a fresh Linux computer per coding agent, with templates, sleep, preview URLs, per-second billing of awake time, and per-seat plans with hour bundles. **Where it's weaker for this use:** memory-preserving sleep is only partly documented. ### 8. GitHub Codespaces *At a glance (source: GitHub Codespaces docs, checked 2026-10-24): isolation: container on a VM · resume keeps processes: Not documented · longest session: 12 h maximum lifetime; idle timeout 30 min default, up to 240 min · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/github-codespaces).* **Best for:** work that lives in GitHub and uses `devcontainer.json`, with free monthly hours on personal accounts and port visibility controls. **Where it's weaker for this use:** a codespace has a 12-hour maximum lifetime and runs as a container on a VM. ### 9. Zo Computer *At a glance (source: Zo Computer docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Partly · longest session: no limit on paid plans (always on) · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/zo-computer).* **Best for:** a personal always-on cloud computer with an AI assistant, file storage and website hosting, driven mostly through chat, on flat monthly plans. **Where it's weaker for this use:** it's built for a personal assistant rather than running your own coding agents at scale. ### 10. Cube Computer *At a glance (source: Cube Computer docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: No · longest session: no limit · price: $0.036/hour · [full comparison and sources](/compare/cube-computer).* **Best for:** one always-on cloud Linux machine for your own agents, managed from a Mac app, browser or phone, on prepaid credit. **Where it's weaker for this use:** no memory-preserving pause and no public URL per port in its docs. ### 11. Sealos DevBox *At a glance (source: Sealos DevBox docs, checked 2026-10-24): isolation: Kubernetes-based (isolation not stated) · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/sealos-devbox).* **Best for:** teams deploying to Sealos who want IDE connections, public preview URLs and predictable monthly packages. **Where it's weaker for this use:** it's Kubernetes-based and the isolation type isn't stated. ### 12. Ona *At a glance (source: Ona docs, checked 2026-10-24): isolation: VM per environment · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/ona).* **Best for:** enterprises that need environments inside their own AWS or GCP VPC, defined as Dev Containers with org-wide guardrails. **Where it's weaker for this use:** its docs say new customer sign-up is closed, and its site describes Ona as part of OpenAI. ## What it costs Dev environments price in three ways: per second or minute while awake (Sandboxer, boxes.dev, Namespace, Cube Computer), credit or hour bundles (Lightning AI, boxes.dev plans), or flat monthly plans (exe.dev pools, Sealos, Zo). For agents that run in bursts, per-second billing plus a free stopped state usually costs least. On Sandboxer the `default` size (4 vCPU, 8 GB) is $0.036/hour, and a stopped sandbox costs nothing. ## How we tested We read each platform's own docs and pricing pages, recorded what they state with a date, and had a second reviewer check each claim against its source. We didn't benchmark other platforms. Sandboxer's timings, measured by us on 2026-10-24, are in [results and conditions](/guides/bench/README.txt). ## Which should you choose? - **Unattended coding agents, many in parallel, keeping sessions across stops:** Sandboxer. - **A personal box that syncs with your laptop:** Prized. - **Many persistent VMs on a flat pool, or regions outside the US:** exe.dev. - **macOS:** Namespace Devboxes. - **Your work lives in GitHub:** Codespaces. - **GPUs:** Lightning AI Studios. - **Inside your own VPC:** Ona. ## FAQ ### What is the best cloud development environment for AI agents? For agents that run unattended with Docker and need to keep state across pauses, we rank Sandboxer first. For humans working in a browser IDE on GitHub repositories, Codespaces is the natural fit. ### Can Claude Code or Codex run in GitHub Codespaces? They can run in any Linux environment you can install them into. Note that a codespace has a 12-hour maximum lifetime, so long unattended runs need a platform without that cap. ### Which cloud dev environments support macOS? Namespace Devboxes offers macOS on Apple Silicon. Sandboxer is Linux only. ### Which cloud dev environments have GPUs? Lightning AI Studios offers GPUs from T4 to H200. Sandboxer has no GPUs. ### Do cloud dev environments keep running processes when they sleep? It varies. Sandboxer and Amp Orbs document keeping memory across a stop or pause. boxes.dev, Prized, Lightning AI and Zo partly document it; Cube Computer says pausing stops processes. Most others don't say. ## Related - [Best sandboxes for coding agents](/guides/best-sandboxes-for-coding-agents) - [Cloud dev environments compared](/compare/cloud-dev-environments) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) --- # Best microVM platforms in 2026, ranked (Firecracker, Cloud Hypervisor and others) Source: https://sandboxer.com/guides/best-microvm-platforms. Updated 2026-10-24. **Short answer:** For a hosted microVM you can treat as a whole Linux machine, we rank Sandboxer (Cloud Hypervisor) first, then E2B and AWS Lambda MicroVMs (Firecracker), all of which keep memory across a pause. Pick Unikraft Cloud for millisecond scale-to-zero, Northflank for bring-your-own-cloud, and self-hosted Firecracker or microsandbox to run microVMs yourself. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## What is a microVM? A microVM is a lightweight virtual machine: a minimal virtual machine monitor (such as Firecracker or Cloud Hypervisor) runs a guest with its own Linux kernel on hardware virtualization such as KVM. It keeps most of a VM's isolation with much less overhead than a traditional VM. That is why sandbox and serverless platforms use microVMs for untrusted or AI-generated code. For the two monitors most platforms here use, see [Firecracker vs Cloud Hypervisor](/guides/firecracker-vs-cloud-hypervisor); for why the boundary matters, see [Container vs microVM](/guides/container-vs-microvm-for-untrusted-code). ## How to compare microVM platforms 1. **A documented microVM per workload:** the platform says each instance is its own microVM. 2. **Pause with memory:** a paused instance comes back with its memory and running processes. 3. **A full machine:** root, systemd and Docker inside, rather than a single process. 4. **Session length:** no short platform cap on how long an instance runs. 5. **Price:** a published price for a 4 vCPU, 8 GB machine-hour. 6. **Hosted:** managed services rank above tools you run yourself (microsandbox and self-hosted Firecracker are listed last). We apply the criteria in this order; later ones break ties. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-24 | | 2 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 3 | [AWS Lambda MicroVMs](/compare/aws-lambda-microvms) | Firecracker microVM | Yes | Yes | Not documented | $0.50 | 2026-10-24 | | 4 | [Unikraft Cloud](/compare/unikraft-cloud) | microVM per instance (hardware-level isolation) | Yes | Partly | Not documented | — | 2026-10-24 | | 5 | [Northflank Sandboxes](/compare/northflank-sandboxes) | microVM on Cloud Hypervisor (gVisor for GPU sandboxes) | Not documented | Yes | Not documented | $0.13 | 2026-10-24 | | 6 | [Fly.io Sprites](/compare/fly-sprites) | microVM (hardware-isolated) | Partly | Yes | Not documented | $0.33 | 2026-10-24 | | 7 | [Docker Sandboxes](/compare/docker-sandboxes) | microVM (hypervisor) | Not documented | Yes | Not documented | — | 2026-10-24 | | 8 | [Vercel Sandbox](/compare/vercel-sandbox) | microVM | No | Partly | Not documented | $0.68 | 2026-10-15 | | 9 | [Cloudflare Sandbox](/compare/cloudflare-sandbox) | microVM per container instance (docs: each instance has its own kernel) | No | Partly | Not documented | $0.36 | 2026-10-24 | | 10 | [Deno Sandbox](/compare/deno-sandbox) | Linux microVM | Not documented | Not documented | Not documented | — | 2026-10-24 | | 11 | [microsandbox](/compare/microsandbox) | microVM (libkrun) | Yes | Partly | Not documented | — | 2026-10-24 | | 12 | [Self-hosted Firecracker](/compare/self-hosted-firecracker) | Firecracker microVM (KVM) | Yes | Partly | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The platforms, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** a whole Linux machine per microVM. Each sandbox is a Cloud Hypervisor microVM running Ubuntu 24.04 with root, systemd, Docker and `/dev/kvm` (nested virtualization), an HTTPS URL for any port and a public IPv6 address. Stopping saves memory and device state, and a stopped sandbox costs nothing. **Where others win:** no GPUs, hosted only, and no SLA during early access. ### 2. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** open-source Firecracker sandboxes. Pause keeps memory and running processes, fork makes running copies, and the stack is Apache-2.0 with Terraform for your own AWS or GCP account. **Where it's weaker for this use:** sessions are capped per plan. ### 3. AWS Lambda MicroVMs *At a glance (source: AWS Lambda MicroVMs docs, checked 2026-10-24): isolation: Firecracker microVM · resume keeps processes: Yes · longest session: 8 hours per MicroVM, not adjustable · price: $0.50/hour · [full comparison and sources](/compare/aws-lambda-microvms).* **Best for:** teams on AWS. Firecracker microVMs built from your Dockerfile image, with suspend and resume that keep memory and disk and no compute charge while suspended, lifecycle hooks, and egress through your own VPC. **Where it's weaker for this use:** each MicroVM is limited to 8 hours, and that limit can't be changed. ### 4. Unikraft Cloud *At a glance (source: Unikraft Cloud docs, checked 2026-10-24): isolation: microVM per instance (hardware-level isolation) · resume keeps processes: Yes · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/unikraft-cloud).* **Best for:** many small, bursty services. Unikraft Cloud documents Firecracker microVMs with cold starts under 10 ms, millisecond scale-to-zero, millisecond resume of stateful apps, VM forking and checkpoints, and hosted, dedicated, BYOC and on-prem options. **Where it's weaker for this use:** it runs Dockerfile images, but a Docker daemon inside isn't documented, nor are root or session length. ### 5. Northflank Sandboxes *At a glance (source: Northflank Sandboxes docs, checked 2026-10-24): isolation: microVM on Cloud Hypervisor (gVisor for GPU sandboxes) · resume keeps processes: Not documented · longest session: no limit · price: $0.13/hour · [full comparison and sources](/compare/northflank-sandboxes).* **Best for:** bring-your-own-cloud and GPUs. Cloud Hypervisor microVMs (gVisor for GPU workloads), sandboxes that run until you delete them, and deployment into your own cloud. **Where it's weaker for this use:** whether a pause keeps memory isn't documented. ### 6. Fly.io Sprites *At a glance (source: Fly.io Sprites docs, checked 2026-10-24): isolation: microVM (hardware-isolated) · resume keeps processes: Partly · longest session: no maximum stated · price: $0.33/hour · [full comparison and sources](/compare/fly-sprites).* **Best for:** persistent microVMs that cost almost nothing while idle, with checkpoints and an always-on HTTPS URL. **Where it's weaker for this use:** after a longer idle period the in-memory state is dropped. ### 7. Docker Sandboxes *At a glance (source: Docker Sandboxes docs, checked 2026-10-24): isolation: microVM (hypervisor) · resume keeps processes: Not documented · longest session: cloud: 1 hour by default · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/docker-sandboxes).* **Best for:** microVMs on your own laptop, free: Docker's `sbx` CLI gives each agent a microVM with a private Docker daemon, with a cloud option on Docker-managed infrastructure. **Where it's weaker for this use:** cloud sandboxes expire after one hour by default. ### 8. Vercel Sandbox *At a glance (source: Vercel Sandbox docs, checked 2026-10-15): isolation: microVM · resume keeps processes: No · longest session: see comparison · price: $0.68/hour · [full comparison and sources](/compare/vercel-sandbox).* **Best for:** apps on Vercel: Firecracker microVMs with active-CPU billing, many regions and VPC peering. **Where it's weaker for this use:** resume doesn't keep running processes. ### 9. Cloudflare Sandbox *At a glance (source: Cloudflare Sandbox docs, checked 2026-10-24): isolation: microVM per container instance (docs: each instance has its own kernel) · resume keeps processes: No · longest session: no fixed limit stated · price: $0.36/hour · [full comparison and sources](/compare/cloudflare-sandbox).* **Best for:** apps on Cloudflare Workers; each container instance runs in its own microVM, billed in 10 ms steps of active time. **Where it's weaker for this use:** no resume with memory. ### 10. Deno Sandbox *At a glance (source: Deno Sandbox docs, checked 2026-10-24): isolation: Linux microVM · resume keeps processes: Not documented · longest session: 30 minutes per session; extendable · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/deno-sandbox).* **Best for:** short code-execution jobs from JavaScript or Python: Linux microVMs that Deno says start in under 200 ms, with volumes and filesystem snapshots, and two regions. **Where it's weaker for this use:** sessions are 30 minutes (extendable), and its snapshots are filesystem-only in the docs we read. ### 11. microsandbox *At a glance (source: microsandbox docs, checked 2026-10-24): isolation: microVM (libkrun) · resume keeps processes: Yes · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/microsandbox).* **Best for:** embedding microVMs in your own app or running them locally: open source (Apache-2.0), libkrun microVMs, full snapshots of memory and processes, and copy-on-write fork. **Where it's weaker for this use:** it's beta software, its hosted cloud isn't priced yet, and locally you run and operate it yourself. ### 12. Self-hosted Firecracker *At a glance (source: Self-hosted Firecracker docs, checked 2026-10-24): isolation: Firecracker microVM (KVM) · resume keeps processes: Yes · longest session: no limit · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/self-hosted-firecracker).* **Best for:** building your own platform with full control. Firecracker is open source (Apache 2.0), with snapshot and resume of memory and device state, on your own x86_64 or aarch64 hardware. **Where it's weaker for this use:** you build everything around it yourself: networking, images, a public URL per port and an API. ## Running microVMs yourself vs a hosted platform Running Firecracker or Cloud Hypervisor yourself gives you full control and no platform fees. It also means you build and operate the parts a hosted platform provides: - host provisioning with KVM; - networking and egress filtering (Firecracker's docs say guest egress "should be filtered at the host-level"); - images, snapshots and their storage; - per-port HTTPS routing; - an API and SDKs. Choose self-hosting when you have the engineering time, or a hard requirement for your own data centre. ## How we tested We read each platform's own docs and pricing pages, recorded what they state with a date, and had a second reviewer check each claim against its source. We didn't benchmark other platforms. Sandboxer's own numbers, measured by us on 2026-10-24 over 10 runs from a client with a 27 ms round trip ([conditions and results](/guides/bench/README.txt)): ready in 3.19–3.20 s, resume with memory in 1.34–1.37 s, process kept its PID in 10 of 10 runs ([script](/guides/bench/create-resume.sh)). Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## Which should you choose? - **A whole Linux machine per microVM, kept in memory across pauses:** Sandboxer. - **Open source:** E2B (hosted or in your cloud), microsandbox or self-hosted Firecracker (on your own machines). - **You run on AWS:** AWS Lambda MicroVMs. - **Bursty services that scale to zero in milliseconds:** Unikraft Cloud. - **Your own cloud or GPUs:** Northflank. - **Free, on your laptop:** Docker Sandboxes. ## FAQ ### What is the difference between a microVM and a container? A container shares the host's kernel and is isolated with namespaces and cgroups. A microVM runs its own kernel behind hardware virtualization, which is a stronger boundary for untrusted code. See [Container vs microVM](/guides/container-vs-microvm-for-untrusted-code). ### Which platforms use Firecracker? E2B, Vercel Sandbox, AWS Lambda MicroVMs and Unikraft Cloud document Firecracker. Sandboxer and Northflank use Cloud Hypervisor, and microsandbox uses libkrun. ### Which microVM platforms keep memory when paused? Sandboxer, E2B, AWS Lambda MicroVMs, Unikraft Cloud and microsandbox document pause, suspend or snapshots with memory. Self-hosted Firecracker supports memory snapshots, which you wire up yourself. ### Can I run Docker inside a microVM? Yes, if the platform gives you a full Linux guest. On Sandboxer, Docker is preinstalled in every sandbox; Docker Sandboxes gives each agent a private Docker daemon. See [Docker and nested KVM in an agent sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox). ### Should I build my own microVM platform? Only if you need full control or your own hardware and have the time to build networking, images, snapshots and an API around Firecracker or Cloud Hypervisor. Otherwise a hosted platform is faster to adopt. ## Related - [Firecracker vs Cloud Hypervisor](/guides/firecracker-vs-cloud-hypervisor) - [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code) - [Top AI sandbox platforms for code execution](/guides/top-ai-sandbox-platforms-for-code-execution) --- # Best tools to give an LLM a full computer in 2026, ranked (MCP servers and computer-use APIs) Source: https://sandboxer.com/guides/best-tools-to-give-an-llm-a-full-computer. Updated 2026-10-27. **Short answer:** Connect your agent to a cloud VM it can control through MCP or an API. We rank Sandboxer first: its MCP server lets Claude, Codex or Cursor create Ubuntu microVMs, run commands, edit files, publish ports and stop them. Freestyle and E2B give agents full VMs too; Orgo offers a hosted MCP server, and Daytona and Coasty computer-use APIs. > **Published by Sandboxer, and we're on this list.** We ranked by the criteria below and say where others are the better choice. Every fact about another platform comes from its own docs or pricing page, checked on the date shown; follow each name for the full comparison and sources. ## What "a full computer" means for an LLM An LLM can only act through tools. Giving it a computer means giving it tools that reach a real machine: run a shell command, read and write files, start a server and get its URL, and for computer use, take a screenshot and click. The machine should be its own, isolated, and disposable, so a mistake or a prompt injection can't reach yours. There are two common ways to connect it: - **An MCP server** the agent's client (Claude Code, Claude Desktop, Cursor, Codex) calls as tools. - **An SDK or REST API** your own agent code calls. ## How to compare them 1. **A full machine:** root, Docker and a real OS, not just a code interpreter. 2. **Agent-callable:** an MCP server or an API with commands, files and ports. 3. **A screen for computer use:** a desktop the agent can see and drive. 4. **State across pauses:** the machine comes back with its memory. 5. **Session length:** long enough for real work. 6. **Self-serve:** sign up and start today. We apply the criteria in this order; later ones break ties. Hosted per-session runtimes (Claude Managed Agents, OpenAI's Code Interpreter) come last because they aren't machines you keep. ## Comparison table | # | Platform | Isolation | Resume keeps running processes | Docker / full Linux | Nested KVM | 4 vCPU / 8 GB, per hour | Checked | | - | - | - | - | - | - | - | - | | 1 | **Sandboxer** | microVM (Cloud Hypervisor, its own kernel) | Yes | Yes | Yes | $0.036 | 2026-10-27 | | 2 | [Freestyle](/compare/freestyle) | full Linux VM (hypervisor not named) | Yes | Yes | Not documented | $0.26 | 2026-10-24 | | 3 | [E2B](/compare/e2b) | microVM | Yes | Partly | No | $0.33 | 2026-10-15 | | 4 | [Daytona](/compare/daytona) | container or VM | Partly | Partly | Partly | $0.33 | 2026-10-15 | | 5 | [Orgo](/compare/orgo) | Not documented | Partly | Not documented | Not documented | — | 2026-10-24 | | 6 | [Novita Agent Sandbox](/compare/novita-agent-sandbox) | Not documented | Yes | Not documented | Not documented | $0.23 | 2026-10-24 | | 7 | [Coasty](/compare/coasty) | Coasty-hosted VM (technology not stated) | Partly | Not documented | Not documented | — | 2026-10-24 | | 8 | [Claude Managed Agents (Anthropic)](/compare/claude-managed-agents) | Linux container on Anthropic-managed infrastructure | Not documented | Partly | Not documented | — | 2026-10-24 | | 9 | [OpenAI Containers (Code Interpreter)](/compare/openai-containers) | Sandboxed virtual machine (per OpenAI docs) | No | No | Not documented | — | 2026-10-24 | "Not documented" means the platform's public docs don't say. Prices are list prices for one 4 vCPU, 8 GB machine-hour; a dash means the platform doesn't sell that size by the hour or doesn't publish a price for it. ## The tools, ranked ### 1. Sandboxer *At a glance (source: our docs and pricing): isolation: microVM (Cloud Hypervisor, its own kernel) · resume keeps processes: Yes · longest session: No limit on paid plans · price: $0.036/hour.* **Best for:** giving Claude, Codex, Cursor or your own agent a whole Linux machine as tools. Sandboxer's MCP server runs locally (`sbx mcp`, built into the `sbx` CLI) or hosted at `https://sandboxer.com/mcp` (Streamable HTTP, signed in with an API key or OAuth). Its 18 shared tools cover the sandbox lifecycle (create, list, get, extend, rename, label, stop, resume, delete), commands (`exec`, with status and kill for detached runs), files (`read_file`, `write_file`), ports (`host_port`) and named snapshots (save, list, delete); the local server adds a 19th, `handoff_session`, to move a running coding-agent session into a sandbox. Each sandbox is a Cloud Hypervisor microVM with Ubuntu 24.04, root and Docker, and stop/resume that keeps memory. Each also has a desktop with Chrome: Sandboxer's built-in agents get computer-use tools for it, and your own agent can drive it with `xdotool` or Playwright through `exec`. ```bash curl -fsSL https://sandboxer.com/install | sh # sbx CLI; signs you in claude mcp add sandboxer -- sbx mcp # Claude Code claude mcp add --transport http sandboxer https://sandboxer.com/mcp # hosted, no CLI needed codex mcp add sandboxer -- sbx mcp # Codex ``` Then ask for something that needs a machine: "Spin up a sandbox, clone the repo, run the tests and give me a preview URL." See [MCP server docs](/docs/mcp). **Where others win:** no Windows desktops, no GPUs. ### 2. Freestyle *At a glance (source: Freestyle docs, checked 2026-10-24): isolation: full Linux VM (hypervisor not named) · resume keeps processes: Yes · longest session: no maximum stated · price: $0.26/hour · [full comparison and sources](/compare/freestyle).* **Best for:** full Linux VMs for agents with live forking, pause and resume that keeps memory, SSH, and HTTPS domains per port. **Where it's weaker for this use:** persistent VMs need a paid plan, and the hypervisor isn't named in its docs. ### 3. E2B *At a glance (source: E2B docs, checked 2026-10-15): isolation: microVM · resume keeps processes: Yes · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/e2b).* **Best for:** building your own computer-use agent: Firecracker microVMs with a desktop SDK and VNC streaming, open source, with pause that keeps memory. **Where it's weaker for this use:** sessions are capped per plan. ### 4. Daytona *At a glance (source: Daytona docs, checked 2026-10-15): isolation: container or VM · resume keeps processes: Partly · longest session: see comparison · price: $0.33/hour · [full comparison and sources](/compare/daytona).* **Best for:** computer use on Linux and Windows through Daytona's computer-use API, with SDKs in TypeScript, Python, Ruby, Go and Java. **Where it's weaker for this use:** VM isolation and memory-saving stop depend on the sandbox class. ### 5. Orgo *At a glance (source: Orgo docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Partly · longest session: no limit stated · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/orgo).* **Best for:** desktops built for agents, on Linux and Windows, with a browser, reachable over HTTP and VNC, and driven by API, CLI, SDKs or a hosted MCP server. **Where it's weaker for this use:** its docs don't state the isolation type, and memory-preserving pause is only partly documented. ### 6. Novita Agent Sandbox *At a glance (source: Novita Agent Sandbox docs, checked 2026-10-24): isolation: Not documented · resume keeps processes: Yes · longest session: 1 h on free tier, 24 h on paid · price: $0.23/hour · [full comparison and sources](/compare/novita-agent-sandbox).* **Best for:** pay-as-you-go sandboxes where browser use and desktop computer use (with VNC viewing) are listed as supported, with pause that keeps memory. **Where it's weaker for this use:** sessions are capped (1 hour on the free tier, 24 hours on paid). ### 7. Coasty *At a glance (source: Coasty docs, checked 2026-10-24): isolation: Coasty-hosted VM (technology not stated) · resume keeps processes: Partly · longest session: TTL 5 min to 7 days, or none · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/coasty).* **Best for:** a computer-use agent as a service: a task API that takes one goal and works on managed Linux or Windows machines, or your own enrolled device. **Where it's weaker for this use:** the VM technology isn't named, and memory-preserving pause is only partly documented. ### 8. Claude Managed Agents *At a glance (source: Claude Managed Agents (Anthropic) docs, checked 2026-10-24): isolation: Linux container on Anthropic-managed infrastructure · resume keeps processes: Not documented · longest session: Not documented · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/claude-managed-agents).* **Best for:** letting Anthropic run the Claude agent loop for you, in per-session Linux containers with many languages, Chromium and Playwright preinstalled. **Where it's weaker for this use:** Claude only, containers rather than VMs, and no machine you keep. ### 9. OpenAI Containers (Code Interpreter) *At a glance (source: OpenAI Containers (Code Interpreter) docs, checked 2026-10-24): isolation: Sandboxed virtual machine (per OpenAI docs) · resume keeps processes: No · longest session: expires after 20 minutes without use · price: No 4 vCPU / 8 GB list price · [full comparison and sources](/compare/openai-containers).* **Best for:** quick code execution and data analysis inside the OpenAI API, with no infrastructure of your own. **Where it's weaker for this use:** a code interpreter rather than a whole computer: no root, and a container expires after 20 minutes without activity. ## How we tested We read each platform's own docs and pricing pages, recorded what they state with a date, and had a second reviewer check each claim against its source. We checked Sandboxer's MCP server against our [MCP docs](/docs/mcp) and ran it on 2026-10-26, both locally (`npx -y sandboxer-mcp`) and hosted (`https://sandboxer.com/mcp` with an API key), recorded in our test log: each listed the same 18 tools and listed sandboxes on a real account (the local server has since added a 19th, `handoff_session`), and the hosted endpoint refused calls without a key. Our create and resume timings, measured on 2026-10-24, are in [results and conditions](/guides/bench/README.txt). ## Which should you choose? - **Give Claude Code, Codex or Cursor a whole Linux machine as tools:** Sandboxer (`sbx mcp`). - **A desktop for computer use, including Windows:** Orgo or Daytona. - **Build your own computer-use agent on open source:** E2B. - **A computer-use agent as a service:** Coasty. - **Anthropic or OpenAI should host everything:** Claude Managed Agents or OpenAI's Code Interpreter containers. ## FAQ ### How do I give Claude Code its own computer? Add an MCP server that controls cloud machines. With Sandboxer: install the `sbx` CLI, then `claude mcp add sandboxer -- sbx mcp`. Claude Code can then create a sandbox, run commands in it, edit files and give you a preview URL. ### What is an MCP server for a sandbox? A Model Context Protocol server exposes a machine's actions (create, exec, read and write files, open a port, stop) as tools that MCP clients like Claude Desktop, Cursor and Codex can call. ### Can an LLM use a desktop, not just a shell? Yes, with a computer-use setup: the agent takes screenshots and sends mouse and keyboard actions. Sandboxer's built-in agents get these tools for the sandbox's desktop (the MCP server itself provides shell, file and port tools); E2B, Daytona, Orgo and Coasty offer computer-use desktops or APIs. ### Is it safe to let an LLM control a computer? Only one that holds nothing else. Use a disposable VM with none of your secrets (`sbx new --no-env` on Sandboxer), pass only the keys the task needs, and review what it produces. ### What does it cost? Sandboxer bills per second while a sandbox runs, $0.036/hour for the `default` size, and nothing while it's stopped. Model usage is billed separately by your model provider. ## Related - [MCP server docs](/docs/mcp) - [What is an AI agent sandbox?](/guides/what-is-an-ai-agent-sandbox) - [Best sandboxes for browser and computer-use agents](/guides/best-sandboxes-for-browser-and-computer-use-agents) --- # How to Add an MCP Server to ChatGPT That Runs Code in a VM Source: https://sandboxer.com/guides/add-an-mcp-server-to-chatgpt. Updated 2026-10-27. **Short answer:** In ChatGPT on the web, open Plugins, click +, choose Add custom MCP server, enter https://sandboxer.com/mcp with OAuth, create and install the plugin, then type @ in a chat. OpenAI lists full MCP, including the write tools that run code, for Business and Enterprise/Edu. Developer mode is no longer required. ## At a glance | | Detail | | - | - | | Server URL | `https://sandboxer.com/mcp` | | Transport | Streamable HTTP (ChatGPT supports SSE and streaming HTTP) | | Sign-in in ChatGPT | OAuth: you sign in to Sandboxer and click **Allow**. ChatGPT doesn't take an API key | | Sign-in from code | `Authorization: Bearer ` | | Tools | 18, of which 5 are read-only and 13 ask for confirmation in ChatGPT | | Where it runs | Ubuntu microVMs on Sandboxer, not on OpenAI's servers or your computer | | ChatGPT surface | Web only, not mobile | ## Which ChatGPT plans can do this OpenAI's help article [Connecting custom MCP servers in ChatGPT](https://help.openai.com/en/articles/12584461-connecting-custom-mcp-servers-in-chatgpt) says, as of October 27, 2026: | Plan | What OpenAI's article says | | - | - | | Business | Custom MCP server connections and full MCP support, on ChatGPT web. Only admins and owners can create and publish plugins for custom MCP servers. | | Enterprise/Edu | Same, and admins can use role-based access control to let selected members create connections. Only admins and owners publish. | | Pro | "Full MCP is only available to Business and Enterprise/Edu users, currently. Pro users can connect custom MCP servers with read/fetch permissions." | | Plus, Free | Not listed in the article. | What that means for running code: `create_sandbox` and `exec` are write tools (they don't carry `readOnlyHint`), so a ChatGPT account limited to read/fetch can't use them. Running code from ChatGPT needs the full MCP support that OpenAI lists for Business and Enterprise/Edu. Workspace policies apply on top: the creation permission is called **Create plugins with MCPs**. Developer mode: the same article says that in the current Plugins experience you add a custom MCP server directly and "don't need to turn on Developer Mode first". You accept a risk warning for each server instead. Some workspaces still use the legacy path, **Settings > Apps > Create**, which scans the tools and saves a draft app marked **Dev**. `search` and `fetch` tools: no longer required. OpenAI's [Add custom MCP server](https://developers.openai.com/api/docs/guides/custom-mcp-server) guide says custom MCP servers "do not require `search`/`fetch` tools". Two features still look for them: company knowledge only includes apps with search/fetch, and deep research uses custom apps for read/fetch actions only. Sandboxer has no `search` or `fetch` tool, so use it in a normal chat. ## Add Sandboxer to ChatGPT 1. Open [chatgpt.com/plugins](https://chatgpt.com/plugins) on the web. 2. Click **+**, then **Add custom MCP server**. 3. Name it `Sandboxer`. Under **Connection**, enter `https://sandboxer.com/mcp`. 4. Choose **OAuth** for authentication. Sandboxer supports both client registration methods ChatGPT uses (Client ID Metadata Documents and dynamic client registration), so leave the client fields empty. 5. Read the risk warning, check **I understand and want to continue**, then click **Create as a plugin** and review the 18 discovered tools. 6. Install the plugin from your personal plugins (or the workspace where you created it), sign in to Sandboxer and click **Allow**. 7. In a new conversation, type `@` and pick Sandboxer. On a Business workspace, an admin or owner does this once and publishes it from the [Admin console](https://admin.openai.com/) (**Plugins > Manage Legacy Apps > Drafts > Publish**). Members then find it under **Settings > Plugins**. After publishing, ChatGPT keeps a frozen copy of the tool list until an admin refreshes it. ## What ChatGPT asks before each tool runs ChatGPT treats any tool without `readOnlyHint` as a write action and asks you to confirm it by default. You can tell it to remember approve or deny for one tool for the rest of a conversation. A new or refreshed conversation asks again. These are the annotations the server returns from `tools/list` today: | Treated as write actions (confirm by default) | Read-only (`readOnlyHint: true`) | | - | - | | `create_sandbox`, `exec`, `kill_command`, `write_file`, `host_port`, `stop_sandbox`, `resume_sandbox`, `extend_sandbox`, `rename_sandbox`, `label_sandbox`, `delete_sandbox`, `save_snapshot`, `delete_snapshot` | `list_sandboxes`, `get_sandbox`, `command_status`, `read_file`, `list_snapshots` | `exec`, `kill_command`, `write_file`, `delete_sandbox` and `delete_snapshot` also carry `destructiveHint: true`. Remembering approval for `exec` is reasonable inside a sandbox you just created, since it only runs in that VM. Keep `delete_sandbox` on confirm. ## A prompt to try ```text @Sandboxer Create a small sandbox named demo. Run python3 -c 'print(2**100)'. Then start `sleep 30; echo done` as a detached command, poll command_status until it exits, and delete the sandbox. ``` We didn't drive ChatGPT for this page (no OpenAI account in the test). We sent the same tool calls ChatGPT would send to `https://sandboxer.com/mcp` on production, with an API key, on October 27, 2026. These are the real results, trimmed: ```text create_sandbox {"name":"aeo-chatgpt-1","type":"small","ttlSeconds":900} → Sandbox sb_x0n1a2hf96 is ready. URL: https://quiet-brisk-jay.box.sandboxer.com. Stops automatically at 2026-10-27T02:26:10Z. exec {"command":"python3 -c 'print(2**100)'"} → Exit code 0. stdout: 1267650600228229401496703205376 exec {"command":"sleep 30; echo done","detached":true} → Started process 529 in sb_x0n1a2hf96. Call command_status with processId 529 for its output and exit code. command_status {"processId":529} → Process 529 is running. command_status {"processId":529} # 35 seconds later → Process 529 exited with code 0. stdout: done delete_sandbox {"sandboxId":"sb_x0n1a2hf96"} → Deleting sb_x0n1a2hf96: its disk, memory and automatic snapshots are removed. ``` Things worth knowing from that run: - **Every result has two parts:** the text above, and the same data as `structuredContent` (for example `exitCode`, `stdout`, and `stdoutNextOffset` to pass back so a poll returns only new output). - **`sandboxId` is optional after the create.** Each tool defaults to the sandbox this MCP session created or used last. `delete_sandbox` is the exception: it always needs the id. - **`exec` waits up to 120 seconds by default** (600 at most). Anything longer, such as a build or a server, should use `detached: true` and `command_status`. `command_status` is read-only, so ChatGPT doesn't treat a poll as a write action. - **Sandboxes made over MCP stop after an hour** unless `ttlSeconds` says otherwise. A stopped sandbox bills no running time. - **Failures come back as tool errors with the API's message** (`isError: true`), for example an unknown sandbox or a plan or start limit, so the model can tell you what went wrong. ## Check the server before you add it If ChatGPT can't scan the tools, check the two things it needs first. Both work without an account: ```bash curl -si -X POST https://sandboxer.com/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ | grep -i -e '^HTTP' -e '^www-authenticate' ``` ```text HTTP/2 401 www-authenticate: Bearer resource_metadata="https://sandboxer.com/.well-known/oauth-protected-resource/mcp", scope="sandboxer" ``` ```bash curl -s https://sandboxer.com/.well-known/oauth-protected-resource/mcp ``` ```json {"authorization_servers":["https://sandboxer.com"],"bearer_methods_supported":["header"],"resource":"https://sandboxer.com/mcp","scopes_supported":["sandboxer"]} ``` `https://sandboxer.com/.well-known/oauth-authorization-server` then lists the authorize, token, register and revoke endpoints, `S256` PKCE, `client_id_metadata_document_supported: true` and the `refresh_token` grant. With a valid key, `initialize` returns server `sandboxer` on protocol `2025-06-18`, and `tools/list` returns the 18 tools. ## The same server from the OpenAI Responses API OpenAI's [MCP servers guide](https://developers.openai.com/api/docs/guides/tools-connectors-mcp) uses a `mcp` tool with `server_url` in the Responses API. The Responses API supports Streamable HTTP and HTTP/SSE servers, and puts the server's token in the `authorization` field. For Sandboxer, that token is a Sandboxer API key: ```bash curl https://api.openai.com/v1/responses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "model": "gpt-6-astra", "tools": [{ "type": "mcp", "server_label": "sandboxer", "server_description": "Cloud sandboxes that run shell commands.", "server_url": "https://sandboxer.com/mcp", "authorization": "'"$SANDBOXER_API_KEY"'", "allowed_tools": ["create_sandbox", "exec", "command_status", "delete_sandbox"], "require_approval": "always" }], "input": "Create a small sandbox, run python3 -c \"print(2**100)\", then delete it." }' ``` We didn't run this request: the test had no OpenAI key. The shape follows OpenAI's guide, and the Sandboxer side of it (a Bearer API key on `/mcp`, `initialize`, `tools/list` and the four tools) is what we ran above. Notes from OpenAI's guide: - **Approvals default to on.** Each call comes back as an `mcp_approval_request` that you answer with an `mcp_approval_response` on the next request. `"never"`, or a `never` list of tool names, skips them. - **The `authorization` value isn't stored**, so send it on every request. - **`allowed_tools` keeps the tool list short**, which lowers cost and latency. The four above cover create, run, poll and clean up. Use a [scoped API key](/docs/api-keys) here. A key with the `ci` preset can create sandboxes and delete only the ones it created. ## FAQ ### Do I need ChatGPT developer mode to add an MCP server? Not in the current Plugins experience. OpenAI's help article says you add the server from Plugins and accept a risk warning, without turning on developer mode first. Workspace policies still decide whether you can. ### Can ChatGPT Plus or Pro run code through an MCP server? OpenAI's article lists full MCP, including write actions, for Business and Enterprise/Edu only, and says Pro can connect custom MCP servers with read/fetch permissions. Plus isn't listed. Running code uses write tools, so it needs Business or Enterprise/Edu. ### Can I paste a Sandboxer API key into ChatGPT instead of signing in? No. ChatGPT offers OAuth, no authentication, or a mix of the two for custom MCP servers, and Sandboxer always requires sign-in, so choose OAuth. API keys are for code, such as the Responses API or the Agents SDK. ### How do I take away ChatGPT's access? Open [API keys](/app/api-keys) in Sandboxer. The connection is listed as "ChatGPT (MCP)". Click **Disconnect** and its token stops working at once. The token only ever worked on `/mcp`, not on the REST API. ### Can ChatGPT connect to `sbx mcp` on my laptop? Not directly. ChatGPT connects to remote servers only, and OpenAI points to its Secure MCP Tunnel for local ones. The hosted `https://sandboxer.com/mcp` has the same tools, minus `handoff_session`, so there's no need for a tunnel. ## Related - [MCP server docs](/docs/mcp) - [OpenAI Agents SDK](/docs/openai-agents) - [API keys and scopes](/docs/api-keys) - [What is an AI agent sandbox?](/guides/what-is-an-ai-agent-sandbox) --- # Connect Claude.ai to a Cloud Computer with a Custom MCP Connector Source: https://sandboxer.com/guides/connect-claude-ai-to-a-cloud-computer. Updated 2026-10-27. **Short answer:** In Claude, open Customize > Connectors, click + Add, then Add custom connector. Name it Sandboxer, enter https://sandboxer.com/mcp, choose Sign in now, then sign in to Sandboxer and click Allow. Turn it on from + > Connectors in a chat, and Claude can create Linux sandboxes, run commands, serve ports and delete them. ## At a glance | Topic | Detail | | - | - | | Server URL | `https://sandboxer.com/mcp` (remote MCP, Streamable HTTP) | | Sign-in | OAuth with your Sandboxer account, or a Sandboxer API key sent as `Authorization: Bearer ` | | Claude plans | Free, Pro, Max, Team and Enterprise; the Free plan allows one custom connector ([Anthropic](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp)) | | Claude apps | Claude, Cowork and Claude Desktop, per the same Anthropic article | | Tools Claude gets | 18: create, inspect, run commands, files, ports, stop and resume, snapshots, delete | | What runs where | Commands run in a Sandboxer microVM (Ubuntu 24.04), not on your computer | | Cost | Sandbox time on your Sandboxer account: `default` is $0.036/hour, billed per second; a stopped sandbox bills no running time | **What we checked and what we didn't.** The Claude.ai steps below come from Anthropic's help article [Get started with custom connectors using remote MCP](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp); we didn't click through Claude.ai for this page. Everything on the Sandboxer side we ran against production on 2026-10-27: the OAuth discovery chain Claude follows, the `401` it gets first, and a full API-key session (`initialize`, `tools/list`, and creating and deleting a sandbox with `tools/call`). Those outputs are below. ## 1. Add Sandboxer as a custom connector (Free, Pro, Max) These are Anthropic's steps, with Sandboxer's values filled in: 1. Go to **Customize > Connectors**. 2. Click **+ Add**, then **Add custom connector**. 3. Enter a name, for example `Sandboxer`. You see it in the connectors list. 4. Enter the remote MCP server URL `https://sandboxer.com/mcp` and click **Continue**. 5. Review the detected authentication settings and click **Continue**. 6. Under **Authentication**, choose **Sign in now** (or **Sign in when needed**). 7. Under **OAuth client**, keep **Use Claude's published identity (Recommended)**. Sandboxer also accepts **Register automatically**. 8. Leave **Request headers** empty when you sign in with OAuth. 9. Click **Add**. Sign in to Sandboxer in the window that opens and click **Allow**. Anthropic notes that it doesn't verify custom connectors, so only add servers you trust. To change a connector later, remove it and add it again. **Using an API key instead of sign-in.** Anthropic's form has a **Request headers** field for fixed credentials, and Sandboxer accepts `Authorization: Bearer ` on `/mcp` (shown below). Choose **No sign in** and add that header with a [scoped key](/docs/api-keys) if you want to limit what Claude can do, for example a key that can't delete sandboxes. On Team and Enterprise plans, Anthropic says everyone using a connector with a header credential shares that one credential, so OAuth is the better fit there. ## 2. Team and Enterprise Per Anthropic, an Owner or Primary Owner adds the connector once (on Enterprise, also a custom role with **Manage** access to Libraries): 1. Go to **Organization settings > Connectors** and click **Add**. 2. Hover over **Custom** and select **Web**. 3. Enter `Sandboxer` and `https://sandboxer.com/mcp`, click **Continue**, review authentication, set the options as in step 1 and click **Add**. Each member then opens **Customize > Connectors**, finds Sandboxer (usually labeled **Custom**) and clicks **Connect**. With OAuth, each member signs in with their own Sandboxer account, and the sandboxes Claude starts bill to that account's active wallet. ## 3. Use it in a chat Click **+** at the lower left of the chat box, choose **Connectors** and switch Sandboxer on for that conversation. Then ask in plain words: ```text Create a small sandbox, run uname -a, serve port 8000 with python3 -m http.server, give me the URL, then delete the sandbox. ``` That maps to `create_sandbox`, `exec` (once in the foreground, once with `detached: true` for the server), `host_port` and `delete_sandbox`. Claude doesn't need to repeat the sandbox id: every tool except `delete_sandbox` defaults to the sandbox this connection created or used last. Sandboxes created through MCP stop by themselves after an hour unless `ttlSeconds` says otherwise. ## What Claude sees when it connects Anthropic's servers make the connection, not your browser, so the server has to be on the public internet. `https://sandboxer.com/mcp` is. Here is the chain a client walks, run with `curl` against production. The first request has no token, so it gets a `401` that points at the resource metadata: ```bash curl -sS -i -X POST https://sandboxer.com/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' ``` ```text HTTP/2 401 www-authenticate: Bearer resource_metadata="https://sandboxer.com/.well-known/oauth-protected-resource/mcp", scope="sandboxer" … {"error":{"code":-32001,"message":"Unauthorized: send Authorization: Bearer , or sign in with OAuth."},"id":null,"jsonrpc":"2.0"} ``` The resource metadata names the authorization server: ```bash curl -sS https://sandboxer.com/.well-known/oauth-protected-resource/mcp ``` ```json {"authorization_servers":["https://sandboxer.com"],"bearer_methods_supported":["header"],"resource":"https://sandboxer.com/mcp","scopes_supported":["sandboxer"]} ``` And the authorization server's metadata lists the endpoints Claude uses for sign-in, with PKCE (`S256`), dynamic registration and Client ID Metadata Documents, which is what Claude's identity options rely on: ```bash curl -sS https://sandboxer.com/.well-known/oauth-authorization-server ``` ```json { "issuer": "https://sandboxer.com", "authorization_endpoint": "https://sandboxer.com/oauth/authorize", "token_endpoint": "https://sandboxer.com/oauth/token", "registration_endpoint": "https://sandboxer.com/oauth/register", "revocation_endpoint": "https://sandboxer.com/oauth/revoke", "client_id_metadata_document_supported": true, "code_challenge_methods_supported": ["S256"], "grant_types_supported": ["authorization_code", "refresh_token"], "scopes_supported": ["sandboxer"], "token_endpoint_auth_methods_supported": ["none"], … } ``` ## Check the server yourself with an API key To see exactly what Claude gets, talk to the same endpoint with a key from [API keys](/app/api-keys) in `SANDBOXER_API_KEY`. Start a session: ```bash curl -sS -i -X POST https://sandboxer.com/mcp \ -H "Authorization: Bearer $SANDBOXER_API_KEY" \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' ``` ```text HTTP/2 200 mcp-session-id: mcps_825b3ugcf3377vuuhoph8fyd … {"id":1,"jsonrpc":"2.0","result":{"capabilities":{"tools":{"listChanged":false}},"instructions":"Tools for Sandboxer cloud sandboxes: Ubuntu microVMs with root, Docker and a public HTTPS URL per port.\nCall create_sandbox first; …","protocolVersion":"2025-06-18","serverInfo":{"name":"sandboxer","title":"Sandboxer",…}}} ``` Send `notifications/initialized` (the server answers `202`), then list the tools, passing the session id back as `Mcp-Session-Id` on every call: ```bash curl -sS -X POST https://sandboxer.com/mcp \ -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Mcp-Session-Id: $SESSION" \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq -r '.result.tools[].name' ``` ```text create_sandbox list_sandboxes get_sandbox exec command_status kill_command read_file write_file host_port stop_sandbox resume_sandbox extend_sandbox rename_sandbox label_sandbox delete_sandbox save_snapshot list_snapshots delete_snapshot ``` That's 18 tools. `handoff_session` from the [MCP docs](/docs/mcp#tools) isn't among them: it only exists in the local `sbx mcp` server, because it moves a session from your own machine. Create a sandbox the way Claude would, with `tools/call`: ```json {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_sandbox","arguments":{"name":"aeo-claudeai-1","type":"small","ttlSeconds":1800}}} ``` The reply is a text summary for Claude plus the same data as `structuredContent`: ```json {"content":[{"type":"text","text":"Sandbox sb_1khl5lx3ov is ready. URL: https://magic-azure-rook.box.sandboxer.com. Stops automatically at 2026-10-27T02:30:45.206689Z."}], "structuredContent":{"id":"sb_1khl5lx3ov","name":"aeo-claudeai-1","state":"ready","type":"small","url":"https://magic-azure-rook.box.sandboxer.com",…}} ``` The rest of the chat prompt from step 3, in the same session and without passing a `sandboxId`: | `tools/call` | Arguments | Result text (trimmed) | | - | - | - | | `exec` | `{"command":"uname -a"}` | `Exit code 0. stdout: Linux magic-azure-rook 6.18.56-sandboxer … x86_64 GNU/Linux` | | `exec` | `{"command":"… python3 -m http.server 8000 --bind 0.0.0.0","detached":true}` | `Started process 533 in sb_1khl5lx3ov.` | | `host_port` | `{"port":8000,"public":true}` | `Port 8000 of sb_1khl5lx3ov is at https://magic-azure-rook-8000.box.sandboxer.com (public).` | | `delete_sandbox` | `{}` | Tool error: `sandboxId is required: the exact id (sb_…) of one sandbox; delete_sandbox has no default` | | `delete_sandbox` | `{"sandboxId":"sb_1khl5lx3ov"}` | `Deleting sb_1khl5lx3ov: its disk, memory and automatic snapshots are removed.` | Fetching the public URL returned `hello from claude` with HTTP 200, and `get_sandbox` afterwards returned `Sandbox not found (not_found)`. The refused delete without an id is deliberate: Claude always has to name the exact sandbox it deletes. ## Manage or disconnect it - **See the connection.** Each connected app appears under [API keys](/app/api-keys) as "Claude (MCP)". **Disconnect** there ends its access at once. - **Know what sign-in grants.** OAuth gives Claude full access to your account through MCP, and sandboxes bill to your active wallet. The connector's token only works on `/mcp`, not on the REST API. - **Limits are the API's.** Plan start limits apply. When the account is out of starts, `create_sandbox` returns a tool error with the `daily_limit_reached` or `rate_limited` code that Claude reads back to you. See [Limits](/docs/limits). ## FAQ ### Can I add a custom connector on the free Claude plan? Yes. Anthropic's help article lists custom connectors using remote MCP on the Free, Pro, Max, Team and Enterprise plans, and limits Free users to one custom connector. ### Does Claude run the commands on my computer? No. Claude calls Sandboxer's tools from Anthropic's cloud, and every command runs inside a Sandboxer microVM. Nothing touches your laptop, and a mistake stays inside that sandbox. ### Do I need an API key to connect Claude.ai? No. Choose a sign-in option and Claude opens Sandboxer's OAuth consent page. An API key in **Request headers** also works if you'd rather not sign in, and a scoped key limits which tools succeed. ### Does it work in Claude Desktop? Yes. Anthropic says custom connectors work in Claude, Cowork and Claude Desktop, and the connection comes from Anthropic's servers in each case. A local `sbx mcp` server in `claude_desktop_config.json` is a different setup; see the [MCP docs](/docs/mcp). ### What does it cost? The connector is free. You pay for sandbox time on your Sandboxer account: the `default` size is $0.036 an hour, billed per second, and stopped sandboxes bill no running time. Your Claude plan is billed by Anthropic. ## Related - [MCP server docs](/docs/mcp) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) - [Best tools to give an LLM a full computer](/guides/best-tools-to-give-an-llm-a-full-computer) - [Limits and error codes](/docs/limits) --- # Local or Hosted MCP for Cursor, Claude Code and Codex? Source: https://sandboxer.com/guides/local-or-hosted-mcp-for-cursor-claude-code-codex. Updated 2026-10-27. **Short answer:** Use the hosted server at https://sandboxer.com/mcp if you want nothing to install, browser sign-in, or web clients like claude.ai and ChatGPT. Use the local stdio server (sbx mcp or npx -y sandboxer-mcp) if you want default sizes and auto-stop for every sandbox, or the handoff_session tool. Both create the same cloud microVMs. ## The decision Both servers call the same Sandboxer API, so the sandboxes are the same: Ubuntu microVMs that run in Sandboxer's cloud, never on your laptop. The difference is where the MCP process runs and how it signs in. | | Hosted (`https://sandboxer.com/mcp`) | Local stdio (`sbx mcp` / `npx -y sandboxer-mcp`) | | - | - | - | | Transport | Streamable HTTP | stdio (the client starts it as a subprocess) | | Install | Nothing | The `sbx` CLI, or Node for `npx` | | Auth | Browser sign-in (OAuth), or `Authorization: Bearer ` | `sbx login`, or `SANDBOXER_API_KEY` in the server's environment | | Works in claude.ai and ChatGPT | Yes | No (web clients can't start a local process) | | Tools | 18 | 19: the same 18 plus `handoff_session` | | Defaults for new sandboxes | Set by the agent per call | `--type` and `--ttl` flags apply to every create | | Connect (our runs, 2026-10-27) | `initialize` 92–98 ms | `sbx mcp`: 2 ms. `npx`: 330–345 ms warm, 5.4–6.5 s on the first run (it downloads `sbx`) | | Each tool call (our runs, 2026-10-27) | `list_sandboxes` 29–31 ms | First call 96–101 ms, then 28–29 ms | Pick hosted when you switch between machines, use a web client, or don't want a binary on your `PATH`. Pick local when the CLI is already installed and signed in, when you want `sbx mcp --type small --ttl 1800` to cap what the agent creates, or when you want the agent to move its own Claude Code or Codex session into a sandbox with `handoff_session`. ## What we measured We drove both servers with raw JSON-RPC from a laptop in Miami against production on 2026-10-27 (a few runs each, so treat the numbers as single measurements, not benchmarks), with an API key in `SANDBOXER_API_KEY`. The local server, through `npx`: ```text sandboxer-mcp: downloading sbx-linux-amd64 (72eab2e) from https://sandboxer.com/dl initialize: 5368 ms (including process start) {"name":"sandboxer","title":"Sandboxer","version":"72eab2e"} protocol 2025-06-18 tools/list: 0 ms 19 tools: create_sandbox, list_sandboxes, get_sandbox, exec, … delete_snapshot, handoff_session list_sandboxes: 98 ms list_sandboxes: 29 ms list_sandboxes: 29 ms create_sandbox: 6148 ms Sandbox sb_rv6rdw14df is ready. URL: https://slick-pacific-pelican.box.sandboxer.com. … exec: 142 ms "Exit code 0.\n\nstdout:\nLinux 6.18.56-sandboxer\n2" delete_sandbox: 30 ms Deleting sb_rv6rdw14df: its disk, memory and automatic snapshots are removed. ``` The hosted server, with the key sent as a bearer token: ```text no key: 401 WWW-Authenticate: Bearer resource_metadata="https://sandboxer.com/.well-known/oauth-protected-resource/mcp", scope="sandboxer" initialize: 95 ms 200 application/json {"name":"sandboxer","title":"Sandboxer","version":"72eab2e"} protocol 2025-06-18 session: yes tools/list: 31 ms 18 tools: create_sandbox, list_sandboxes, get_sandbox, exec, … list_snapshots, delete_snapshot list_sandboxes: 31 ms create_sandbox: 3040 ms Sandbox sb_0wy9k097ht is ready. URL: https://witty-orange-saffron.box.sandboxer.com. … exec: 150 ms "Exit code 0.\n\nstdout:\nLinux 6.18.56-sandboxer\n2" delete_sandbox: 31 ms Deleting sb_0wy9k097ht: its disk, memory and automatic snapshots are removed. ``` What this means in practice: - **Per-call speed is the same.** After the first call, both took about 30 ms. The local server's first call is slower because it opens a TLS connection to the API. - **`npx` costs startup time.** Each launch spends about a third of a second resolving the package, and the first launch downloads `sbx`. The client pays this once per session. - **Sandbox creation dominates.** `create_sandbox` took 3 to 6 seconds in the runs shown, either way, and depends on the machine, not the transport. One earlier hosted create, while the shared test account was under heavy load, took 63 seconds. Creates have been faster since October 28, 2026; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). - **The unauthenticated `401` is how OAuth starts.** Clients read `resource_metadata` and open the sign-in page. ## Claude Code Claude Code's syntax is in [its MCP docs](https://code.claude.com/docs/en/mcp). Hosted, signing in with your Sandboxer account: ```bash claude mcp add --transport http --scope user sandboxer https://sandboxer.com/mcp # then, inside Claude Code: /mcp, choose sandboxer, and sign in ``` Local, using your `sbx login`, with defaults for every sandbox the agent creates: ```bash claude mcp add --scope user sandboxer -- sbx mcp --type small --ttl 1800 ``` Local without the CLI. Claude Code passes its own environment to stdio servers (we checked), so export `SANDBOXER_API_KEY` in the shell that starts `claude`: ```bash claude mcp add --scope user sandboxer -- npx -y sandboxer-mcp ``` Hosted with a key, shared with a project in `.mcp.json`. The single quotes keep `${SANDBOXER_API_KEY}` literal, and Claude Code fills it in from the environment, so the key never lands in the file: ```bash claude mcp add-json --scope project sandboxer '{"type":"http","url":"https://sandboxer.com/mcp","headers":{"Authorization":"Bearer ${SANDBOXER_API_KEY}"}}' ``` What `claude mcp list` showed in a scratch config with all four (each under its own name): ```text sandboxer: https://sandboxer.com/mcp (HTTP) - ! Needs authentication sandboxer-local: sbx mcp --type small --ttl 1800 - ✔ Connected sandboxer-npx: npx -y sandboxer-mcp - ✔ Connected sandboxer-key: https://sandboxer.com/mcp (HTTP) - ⏸ Pending approval (run `claude` to approve) ``` `Needs authentication` is expected for the OAuth entry until you run `/mcp`. A project `.mcp.json` server waits for you to approve it the first time `claude` starts in that directory. `✔ Connected` only means the process started; a local server with no credentials still connects and prints `not signed in` to stderr. ## Codex Codex's syntax is in [its MCP docs](https://developers.openai.com/codex/mcp). Servers go in `~/.codex/config.toml`, or `.codex/config.toml` in a trusted project. Hosted with a key: ```bash codex mcp add sandboxer --url https://sandboxer.com/mcp --bearer-token-env-var SANDBOXER_API_KEY ``` Leave out `--bearer-token-env-var` and run `codex mcp login sandboxer` to sign in instead. Local, using your `sbx login`: ```bash codex mcp add sandboxer -- sbx mcp --type small --ttl 1800 ``` The resulting config, and what `codex mcp list` printed: ```toml [mcp_servers.sandboxer] url = "https://sandboxer.com/mcp" bearer_token_env_var = "SANDBOXER_API_KEY" [mcp_servers.sandboxer-local] command = "sbx" args = ["mcp", "--type", "small", "--ttl", "1800"] ``` ```text Name Command Args Env Cwd Status Auth sandboxer-local sbx mcp --type small --ttl 1800 - - enabled Unsupported Name Url Bearer Token Env Var Status Auth sandboxer https://sandboxer.com/mcp SANDBOXER_API_KEY enabled Bearer token ``` **With `npx`, forward the key explicitly.** Codex doesn't pass your whole environment to stdio servers. In our test, a server started by Codex saw `HOME` but not `SANDBOXER_API_KEY` until we listed it in `env_vars`. Don't use `--env SANDBOXER_API_KEY=...` on the command line: that writes the key itself into `config.toml`. ```toml [mcp_servers.sandboxer] command = "npx" args = ["-y", "sandboxer-mcp"] env_vars = ["SANDBOXER_API_KEY"] ``` ## Cursor Cursor's format is in [its MCP docs](https://cursor.com/docs/context/mcp). Use `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` for one. Hosted, with Cursor opening the Sandboxer sign-in page when it connects: ```json { "mcpServers": { "sandboxer": { "url": "https://sandboxer.com/mcp" } } } ``` Hosted with a key. Cursor fills in `${env:…}` from your environment: ```json { "mcpServers": { "sandboxer": { "url": "https://sandboxer.com/mcp", "headers": { "Authorization": "Bearer ${env:SANDBOXER_API_KEY}" } } } } ``` Local, with the CLI or with `npx`: ```json { "mcpServers": { "sandboxer": { "command": "sbx", "args": ["mcp", "--type", "small", "--ttl", "1800"] }, "sandboxer-npx": { "command": "npx", "args": ["-y", "sandboxer-mcp"], "env": { "SANDBOXER_API_KEY": "${env:SANDBOXER_API_KEY}" } } } } ``` Cursor needs the `command` on its `PATH` or as a full path. A desktop app doesn't always inherit your shell's `PATH`, so use the output of `which sbx` if Cursor can't find it. We didn't have Cursor on the test machine; these files follow Cursor's documented format. ## Check it works Restart or reconnect the client, then ask the agent: ```text Create a small Sandboxer sandbox, run `uname -sr && nproc` in it, show me the output, then delete it. ``` The agent calls `create_sandbox`, `exec` and `delete_sandbox`, the same three calls timed above. `delete_sandbox` always needs the explicit sandbox id, so the agent can't delete one by accident with a default. ## FAQ ### Do the local and hosted servers create different sandboxes? No. Both call the same API with your account, and the sandboxes bill to the same wallet. `list_sandboxes` in either one shows sandboxes created by the other. ### Which one should a CI job or headless agent use? Either, with an API key in `SANDBOXER_API_KEY`, because neither can open a browser there. A [scoped key](/docs/api-keys) such as the `ci` preset limits what the agent can do, tool by tool. ### Why does the local server have one more tool? `handoff_session` moves the Claude Code or Codex session on your machine into a new sandbox. It needs your local files and session, so it only exists in `sbx mcp`. ### How long do sandboxes created through MCP run? They stop after an hour unless `ttlSeconds` says otherwise. With the local server, `--ttl` changes that default for every create. A stopped sandbox bills no running time. ### Will my key end up in a config file? Not with the configs above. Each one reads the key from an environment variable at run time. The risky forms are Claude Code's `--header` with a shell-expanded key and `--env KEY=value` in Claude Code or Codex, which write the value itself. ## Related - [MCP server docs](/docs/mcp) - [API keys and scopes](/docs/api-keys) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) - [Run Codex CLI on a remote machine](/guides/run-codex-cli-on-a-remote-machine) --- # How to Add a Code Execution Tool to the Vercel AI SDK Source: https://sandboxer.com/guides/add-code-execution-to-the-vercel-ai-sdk. Updated 2026-10-27. **Short answer:** Define an AI SDK tool() with a zod inputSchema for language and code. In its execute function, create a Sandboxer sandbox with sandboxer-sdk, write the code to a file, run it with exec and return stdout, stderr and the exit code. Pass the tool to generateText, then delete the sandbox when the run ends. ## At a glance | | Your own `tool()` with `sandboxer-sdk` | Hosted MCP (`https://sandboxer.com/mcp`) | | - | - | - | | What the model sees | One tool you design (`runCode` below) | 18 Sandboxer tools (`create_sandbox`, `exec`, `write_file`, …) | | Who decides the sandbox lifecycle | Your code: create, reuse, delete | The model, through the tools | | Packages | `ai`, `zod`, `sandboxer-sdk` | `ai`, `@ai-sdk/mcp` | | Best for | Production apps that want a narrow, predictable tool | Agents that need files, ports and snapshots too | | Where the code runs | A Sandboxer microVM, never your server | Same | **What we ran.** On 2026-10-27 we installed `ai` 7.0.154, `zod` 4.6.7 and `sandboxer-sdk` 0.1.1 in Node 24 and ran everything below against production. We called the tool's `execute` directly, and we ran the full `generateText` → tool → sandbox loop with the AI SDK's `MockLanguageModelV4` from `ai/test`, scripted to emit one tool call. The mock stands in for the LLM, because we had no model key. The tool call, the sandbox and its output were real; the model's final sentence was scripted. With a real provider, you only change `model`. ## 1. Install ```bash npm i ai zod sandboxer-sdk npm pkg set type=module # the examples use ES modules and top-level await export SANDBOXER_API_KEY=... # create one at sandboxer.com/app/api-keys ``` `new Sandboxer()` reads `SANDBOXER_API_KEY` from the environment, so the key never sits in your code. A [scoped key](/docs/api-keys) is enough. ## 2. Define the tool ```typescript title="tool.ts" import { tool } from "ai"; import { z } from "zod"; import { Sandboxer, type Sandbox } from "sandboxer-sdk"; const sandboxer = new Sandboxer(); // reads SANDBOXER_API_KEY let sandbox: Sandbox | undefined; // One sandbox per agent run, created on the first tool call. async function getSandbox(): Promise { sandbox ??= await sandboxer.create({ type: "small", ttlSeconds: 900, name: process.env.SANDBOX_NAME ?? "ai-sdk-run" }); return sandbox; } export const runCode = tool({ description: "Run Python or a bash command in an isolated Linux sandbox. Returns stdout, stderr and the exit code.", inputSchema: z.object({ language: z.enum(["python", "bash"]), code: z.string().describe("The full program or command to run"), }), execute: async ({ language, code }) => { const box = await getSandbox(); const file = language === "python" ? "main.py" : "main.sh"; await box.files.write(file, code); const run = await box.exec(language === "python" ? ["python3", file] : ["bash", file], { timeoutSeconds: 120 }); return { exitCode: run.exitCode, stdout: run.stdout.slice(-4000), stderr: run.stderr.slice(-4000) }; }, }); export async function cleanup() { if (sandbox) { console.log("sandbox:", sandbox.id); await sandbox.delete(); } } ``` Why it's built this way: - **The code goes to a file, not a shell string.** `files.write` puts the program at `/home/user/main.py`, and `exec` gets an argv array, so quotes and newlines in model-written code can't break the command. - **One sandbox per run.** Later tool calls in the same run reuse it, so a variable the model wrote to disk in step 1 is still there in step 3. Create the sandbox per request (or per user) in a server. - **A non-zero exit is returned, not thrown.** The model sees the traceback in `stderr` and can fix its code on the next step. - **Output is trimmed** to the last 4,000 characters so a noisy program doesn't fill the context window. - **`SANDBOX_NAME`** lets you label each run's sandbox so it's easy to find with `sbx list`. - **`ttlSeconds: 900`** is a safety net: if your process crashes before `cleanup()`, the sandbox stops itself after 15 minutes. A stopped sandbox bills no running time. `exec` waits up to `timeoutSeconds` (1–600). For longer jobs, use `sandbox.start()` and poll; see the [TypeScript SDK](/docs/sdk-typescript). ## 3. Call the tool directly This tests the sandbox side without any model: ```typescript title="direct.ts" import { runCode, cleanup } from "./tool.ts"; try { const out = await runCode.execute!( { language: "python", code: "import platform, sys\nprint(sys.version.split()[0], platform.machine())\nprint(sum(i*i for i in range(1, 101)))" }, { toolCallId: "direct-1", messages: [], context: {} }, ); console.log(out); const bad = await runCode.execute!({ language: "bash", code: "echo to-stderr >&2; exit 3" }, { toolCallId: "direct-2", messages: [], context: {} }); console.log(bad); } finally { await cleanup(); } ``` ```bash node direct.ts ``` ```text { exitCode: 0, stdout: '3.12.3 x86_64\n338350\n', stderr: '' } { exitCode: 3, stdout: '', stderr: 'to-stderr\n' } sandbox: sb_rg21kkaldu ``` The second call shows the error path: exit code 3 and the stderr text come back as data. We ran the `.ts` files directly with Node 24, which strips the types itself. ## 4. Run it through generateText Give the tool to `generateText` and allow more than one step, so the model can read the tool result and answer. Here a mock model plays the LLM: its first response is a tool call, its second is text. ```typescript title="agent.ts" import { generateText, isStepCount } from "ai"; import { MockLanguageModelV4 } from "ai/test"; import { runCode, cleanup } from "./tool.ts"; const usage = { inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 10, text: 10, reasoning: undefined }, }; // A scripted model: step 1 asks for the tool, step 2 answers in text. const model = new MockLanguageModelV4({ doGenerate: [ { content: [{ type: "tool-call", toolCallId: "call_1", toolName: "runCode", input: JSON.stringify({ language: "python", code: "import math\nprint(math.factorial(25))" }), }], finishReason: { unified: "tool-calls", raw: undefined }, usage, warnings: [], }, { content: [{ type: "text", text: "25! is 15511210043330985984000000." }], finishReason: { unified: "stop", raw: undefined }, usage, warnings: [], }, ], }); try { const result = await generateText({ model, tools: { runCode }, stopWhen: isStepCount(5), prompt: "What is 25 factorial? Compute it, don't guess.", }); for (const step of result.steps) { for (const r of step.toolResults) console.log("tool result:", JSON.stringify(r.output)); } console.log("steps:", result.steps.length); console.log("text:", result.text); } finally { await cleanup(); } ``` ```text tool result: {"exitCode":0,"stdout":"15511210043330985984000000\n","stderr":""} steps: 2 text: 25! is 15511210043330985984000000. sandbox: sb_ibz1illj7x ``` The AI SDK parsed the tool call, validated it against the zod schema, ran `execute`, and the factorial was computed by Python inside the sandbox. The whole run, including creating and deleting the sandbox, took 3.2 seconds of wall time in our single run on October 27, 2026. To use a real model, install its provider (for example `@ai-sdk/anthropic` or `@ai-sdk/openai`) and replace `model`. Nothing else changes. `stopWhen: isStepCount(5)` lets the model call the tool, read an error and try again up to five steps; see the AI SDK's [tool calling docs](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling). The mock model is documented under [Testing](https://ai-sdk.dev/docs/ai-sdk-core/testing). ## The alternative: the hosted MCP server If you'd rather give the model Sandboxer's full toolset than write your own tool, connect the AI SDK's [MCP client](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) to `https://sandboxer.com/mcp`: ```bash npm i ai @ai-sdk/mcp ``` ```typescript title="mcp.ts" import { createMCPClient } from "@ai-sdk/mcp"; const mcp = await createMCPClient({ transport: { type: "http", url: "https://sandboxer.com/mcp", headers: { Authorization: `Bearer ${process.env.SANDBOXER_API_KEY}` }, }, }); try { const tools = await mcp.tools(); // pass these to generateText({ tools }) console.log(Object.keys(tools).join(", ")); } finally { await mcp.close(); } ``` ```text create_sandbox, list_sandboxes, get_sandbox, exec, command_status, kill_command, read_file, write_file, host_port, stop_sandbox, resume_sandbox, extend_sandbox, rename_sandbox, label_sandbox, delete_sandbox, save_snapshot, list_snapshots, delete_snapshot ``` We ran this against production with an API key and listed the tools; we didn't run a model over them. With MCP, the model creates and deletes sandboxes itself, so tell it in the system prompt to call `delete_sandbox` or `stop_sandbox` when it's done. Sandboxes created through MCP stop on their own after an hour unless `ttlSeconds` says otherwise. Every tool is described in the [MCP server docs](/docs/mcp). ## What it costs Sandbox time is billed per second while the sandbox runs. A `small` sandbox (2 vCPU, 4 GB) is half the `default` rate of $0.036/hour. Deleting the sandbox at the end of each run, as `cleanup()` does, keeps the bill to the seconds the run used. ## FAQ ### Does the model's code run on my server? No. `execute` runs on your server, but it only sends the code to Sandboxer. The program runs inside a Sandboxer microVM with its own kernel, so a bad `rm -rf` or an infinite loop stays in the sandbox. ### Can I install packages in the sandbox? Yes. The sandbox is an Ubuntu VM with passwordless `sudo`, Python and Node, so the model can run `pip install` or `npm install` through the bash language before running its code. Packages stay for the rest of that sandbox's life. ### Does this work in a Next.js route handler? `sandboxer-sdk` has no runtime dependencies and supports Node 20+, Bun, Deno and edge runtimes. We tested in Node 24 only. Keep `SANDBOXER_API_KEY` in a server-side environment variable, never in client code. ### How do I keep state between requests? Store the sandbox id with the conversation and call `sandboxer.get(id)` on the next request instead of creating a new one. Call `sandbox.stop()` between turns to stop billing and keep its files and memory, and `sandbox.resume()` to continue. ### Should I use my own tool or the MCP server? Use your own `tool()` when you want one narrow capability and your code to control the sandbox. Use MCP when the agent also needs files, public ports or snapshots and you're fine letting the model manage the sandbox. ## Related - [TypeScript SDK](/docs/sdk-typescript) - [MCP server](/docs/mcp) - [Local or hosted MCP for Cursor, Claude Code and Codex](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # Run OpenAI Agents SDK Tools in a Cloud VM and Get Files Back Source: https://sandboxer.com/guides/run-openai-agents-sdk-tools-in-a-cloud-vm. Updated 2026-10-27. **Short answer:** Wrap sandboxer-sdk calls in @function_tool functions, so the agent's shell commands and file reads run in a cloud Ubuntu VM, and add a download tool that copies results to your machine. Or write no tool code at all: connect MCPServerStreamableHttp to https://sandboxer.com/mcp with a Bearer API key and get 18 tools. This guide builds on the [OpenAI Agents SDK docs page](/docs/openai-agents). It adds a file-download tool, a way to test your tools without an OpenAI key, and the hosted MCP server. We ran everything below on Sandboxer with `openai-agents` 0.24.0, `sandboxer-sdk` 0.1.1 and Python 3.13. We had no OpenAI key, so a scripted model stood in for the LLM. Section 4 explains exactly which part that covers. ## Which way to connect | | Function tools (`sandboxer-sdk`) | Hosted MCP (`MCPServerStreamableHttp`) | Local MCP (`MCPServerStdio` + `sbx mcp`) | | - | - | - | - | | What the agent sees | Only the tools you write | 18 tools: create, exec, files, ports, snapshots, stop, delete | The same tools, plus `handoff_session` | | Install | `pip install sandboxer-sdk` | Nothing beyond `openai-agents` | The `sbx` CLI on the host | | Getting files back | Your own tool writes them to the host disk | `read_file` returns text into the model's context (up to 5 MiB) | Same as hosted | | Who decides the sandbox's size and lifetime | Your code | The model, through `create_sandbox` arguments | `sbx mcp --type --ttl` defaults, which the model can override | | Best for | Production agents with a fixed, narrow tool set | Trying it out, and agents that need the whole lifecycle | Desktop or CI hosts that already have `sbx` | Version 0.24 of the Agents SDK also ships sandbox agents, with hosted clients for several providers ([OpenAI docs](https://openai.github.io/openai-agents-python/sandbox/clients/)). Sandboxer isn't one of those clients. Use function tools or MCP instead. ## 1. Install ```bash python3 -m venv .venv && . .venv/bin/activate pip install openai-agents sandboxer-sdk export SANDBOXER_API_KEY=sbx_live_… ``` Install `sandboxer-sdk`, not `sandboxer`. The PyPI package named `sandboxer` is an unrelated project. To run a real model later, also set `OPENAI_API_KEY`. ## 2. Define the tools Save this as `sandbox_tools.py`. It creates one `small` sandbox (2 vCPU, 4 GB, $0.018/hour) the first time the agent calls a tool. Every later call reuses it. ```python title="sandbox_tools.py" import pathlib from agents import Agent, function_tool from sandboxer import AsyncSandboxer, SandboxerError sandboxer = AsyncSandboxer() # reads SANDBOXER_API_KEY sandbox = None # one sandbox per task, created on first use async def get_sandbox(): global sandbox if sandbox is None: sandbox = await sandboxer.create(type="small", ttl_seconds=1800, name="agent-task") return sandbox def tool_error(ctx, error: Exception) -> str: # The SDK's default hides the reason ("An error occurred while running the tool"). # Sandboxer errors are safe to show the model, so it can fix the path or command. if isinstance(error, SandboxerError): return f"error: {error}" return "error: the tool failed" @function_tool(failure_error_function=tool_error) async def run_shell(command: str) -> str: """Run a bash command in the cloud sandbox (Ubuntu 24.04, cwd /home/user). Returns exit code, stdout and stderr.""" sb = await get_sandbox() result = await sb.exec(command, timeout_seconds=300) return f"exit code {result.exit_code}\n{result.stdout}{result.stderr}".strip() @function_tool(failure_error_function=tool_error) async def read_file(path: str) -> str: """Read a UTF-8 text file from the sandbox (up to 5 MiB). Relative paths are under /home/user.""" sb = await get_sandbox() return await sb.files.read(path) @function_tool(failure_error_function=tool_error) async def download_file(path: str) -> str: """Copy a file (up to 50 MiB) from the sandbox into ./artifacts on the host and return where it was saved.""" sb = await get_sandbox() data = await sb.files.download(path) out = pathlib.Path("artifacts") / pathlib.Path(path).name # basename only: the model can't pick a host path out.parent.mkdir(exist_ok=True) out.write_bytes(data) return f"saved {out} ({len(data)} bytes)" agent = Agent( name="Analyst", instructions="Do all work in the sandbox with run_shell. Save results as files and return them with download_file.", tools=[run_shell, read_file, download_file], ) ``` How the tools split the work: - **`read_file`** puts a file's text into the model's context. Use it for logs and small outputs the model must reason about. - **`download_file`** writes the bytes to your disk and gives the model only a one-line receipt. Use it for reports, CSVs, images and archives. The model never sees the contents, so they cost no tokens. - **`tool_error`** exists because, by default, the SDK hides why a tool failed. Without it, the model sees only "An error occurred while running the tool. Please try again." With it, the model sees Sandboxer's error message and can fix its next call. - **The host path stays under your control.** `download_file` keeps only the file's basename and saves it under `./artifacts`, so the model can't write anywhere else on your machine. ## 3. Test the tools without a model A `FunctionTool` has an `on_invoke_tool(ctx, json_args)` method. The Runner calls the same method, so calling it yourself tests the real tool code path with no LLM: ```python title="call_tools.py" import asyncio import json from agents.tool_context import ToolContext import sandbox_tools from sandbox_tools import download_file, read_file, run_shell async def call(tool, **args): raw = json.dumps(args) ctx = ToolContext(context=None, tool_name=tool.name, tool_call_id="manual-1", tool_arguments=raw) return await tool.on_invoke_tool(ctx, raw) # the same entry point the Runner uses async def main() -> None: try: print(await call(run_shell, command=( "python3 -c \"import csv,platform,os; w=csv.writer(open('report.csv','w')); " "w.writerow(['key','value']); w.writerow(['python',platform.python_version()]); " "w.writerow(['cpus',os.cpu_count()])\" && ls -l report.csv" ))) print(await call(read_file, path="report.csv")) print(await call(download_file, path="report.csv")) print(await call(read_file, path="missing.txt")) finally: if sandbox_tools.sandbox is not None: print("sandbox", sandbox_tools.sandbox.id) await sandbox_tools.sandbox.delete() asyncio.run(main()) ``` ```text $ python call_tools.py exit code 0 -rw-r--r-- 1 user user 34 Oct 27 02:08 report.csv key,value python,3.12.3 cpus,2 saved artifacts/report.csv (34 bytes) error: sandbox_direct_failed: no such file: /home/user/missing.txt (status 502) [request_id=req_SqZPHoFraw2G7JosNvx9] sandbox sb_jrdd1ft5ar ``` The whole script took about 8 seconds in our single run on October 27, 2026, including creating and deleting the sandbox. `artifacts/report.csv` is now on your machine. The last call shows what the model gets when it asks for a file that doesn't exist. Since a fix later on October 27, 2026, that call returns `file_not_found` with status 404 and isn't marked retryable (see [API error codes](/guides/sandboxer-api-error-codes)). Python inside the sandbox is 3.12, whatever version runs on your host. ## 4. Run the agent With an OpenAI key, run the agent the usual way: ```python result = await Runner.run(agent, "Check the machine and save when you ran.") ``` We had no OpenAI key, so we didn't run a real model. Instead we ran the Runner with the SDK's `ScriptedModel` (in `agents.testing`), which plays back the tool calls a model would make. The Runner, the tool dispatch, the sandbox and the download were all real. Only the model's decisions were scripted. ```python title="scripted_run.py" import asyncio from agents import Runner from agents.testing import ScriptedModel, assistant_message, function_call import sandbox_tools from sandbox_tools import agent # Stands in for the LLM: each step is one model turn. agent.model = ScriptedModel([ [function_call("run_shell", {"command": "uname -sr && nproc && free -h | head -2 && date -u > ran-at.txt"}, call_id="call_1")], [function_call("download_file", {"path": "ran-at.txt"}, call_id="call_2")], [assistant_message("Done. The timestamp file is in artifacts/ran-at.txt.")], ]) async def main() -> None: result = await Runner.run(agent, "Check the machine and save when you ran.") for item in result.new_items: if item.type == "tool_call_output_item": print(f"--- tool output\n{item.output}") print("--- final_output\n" + result.final_output) print("--- sandbox", sandbox_tools.sandbox.id) asyncio.run(main()) ``` ```text $ python scripted_run.py --- tool output exit code 0 Linux 6.18.56-sandboxer 2 total used free shared buff/cache available Mem: 3.8Gi 304Mi 3.1Gi 352Ki 624Mi 3.5Gi --- tool output saved artifacts/ran-at.txt (32 bytes) --- final_output Done. The timestamp file is in artifacts/ran-at.txt. --- sandbox sb_qdxatv1dvg OPENAI_API_KEY is not set, skipping trace export $ cat artifacts/ran-at.txt Tue Oct 27 02:08:36 AM UTC 2026 ``` The last line of output is the SDK's tracing. With no OpenAI key it skips sending the trace. `ScriptedModel` is also a cheap way to test your tools in CI. ## 5. Get bigger outputs back, then clean up `download_file` handles single files up to 50 MiB. For whole folders or anything bigger, pull them with the [`sbx` CLI](/docs/cli) once the run ends. This sandbox was left running on purpose: ```bash sbx scp sb_qdxatv1dvg:/home/user/ran-at.txt ./ sbx scp -r sb_qdxatv1dvg:/home/user/out ./ sbx delete sb_qdxatv1dvg --yes ``` `sbx scp` prints nothing on success. The `-r` copy brought back an `out/` folder (a 23,893-byte file we had made in the sandbox with `sbx exec`). In your own code, end each task in a `finally` block: - `await sandbox.delete()` removes the sandbox. - `await sandbox.stop()` keeps its files and stops billing. The `ttl_seconds=1800` in `get_sandbox` is the safety net: if your process crashes, the sandbox stops itself after 30 minutes. ## Use the hosted MCP server instead If you'd rather not write tools, point the SDK at Sandboxer's [MCP server](/docs/mcp). It needs no install and no `sbx` binary on the host, which suits containers and serverless functions. ```python title="hosted_mcp.py" import asyncio import os from agents.mcp import MCPServerStreamableHttp async def main() -> None: async with MCPServerStreamableHttp( name="sandboxer", params={ "url": "https://sandboxer.com/mcp", "headers": {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}"}, }, client_session_timeout_seconds=900, # create_sandbox and long exec calls outlast the 5 s default ) as sandboxer: tools = await sandboxer.list_tools() print(len(tools), "tools") for tool in tools: print(f"{tool.name:16} {(tool.description or '').split('.')[0]}") asyncio.run(main()) ``` ```text $ python hosted_mcp.py 18 tools create_sandbox Create a cloud sandbox (an Ubuntu microVM with root, Docker, git, Node and Python) and wait until it is ready list_sandboxes List your sandboxes with their state, URL and labels get_sandbox Show one sandbox: state, URL, when it stops automatically, labels and stored bytes exec Run a shell command (bash -c) in the sandbox command_status Status of a detached command: running or its exit code, and its output kill_command Stop a detached command read_file Read a file from the sandbox (up to 5 MiB) write_file Create or overwrite a file in the sandbox (up to 5 MiB) host_port Serve a port of the sandbox on a stable public HTTPS URL … delete_sandbox Delete a sandbox permanently: its disk, memory and automatic snapshots go (named snapshots saved from it stay) save_snapshot Save the sandbox as a named snapshot to create new sandboxes from, and wait until it is saved list_snapshots List your named snapshots: name, version, labels, created time, stored bytes (what counts toward your storage) and disk size (logical) delete_snapshot Delete one named snapshot (one version, e ``` Pass the server to an agent with `Agent(..., mcp_servers=[sandboxer])` inside the `async with` block. To offer only some tools, add a filter. This version lists exactly five: ```python from agents.mcp import create_static_tool_filter MCPServerStreamableHttp( ..., tool_filter=create_static_tool_filter( allowed_tool_names=["create_sandbox", "exec", "read_file", "write_file", "stop_sandbox"] ), ) # list_tools() → ['create_sandbox', 'exec', 'read_file', 'write_file', 'stop_sandbox'] ``` Over MCP, `read_file` returns a file's text to the model, which is fine for small outputs. To get binary files or large results onto your disk, pair MCP with a `download_file` function tool from section 2, or use `sbx scp` after the run. For a headless agent, use a [scoped API key](/docs/api-keys) (`sbx api-key create agent --preset ci`: it can create sandboxes and delete only the ones it created), so the agent can't touch the rest of your account. ## FAQ ### Can I test OpenAI Agents SDK tools without an OpenAI API key? Yes. Call `tool.on_invoke_tool(ToolContext(...), json_args)` to run one tool directly. To run the full Runner loop, use `ScriptedModel` from `agents.testing`, which plays back tool calls you script. Both run your real tool code against a real sandbox. ### How large a file can the agent send back? `files.download` in `sandboxer-sdk` returns up to 50 MiB. `files.read` and the MCP `read_file` tool return up to 5 MiB. For folders or bigger files, use `sbx scp -r` from the host, which copies over SSH instead of the file API. ### Why does my tool error just say "An error occurred while running the tool"? That's the Agents SDK's default `failure_error_function`, which hides exception details from the model. Pass your own function to `@function_tool(failure_error_function=...)` to return a message the model can act on. ### Does the hosted MCP server work without installing the sbx CLI? Yes. `MCPServerStreamableHttp` connects to `https://sandboxer.com/mcp` over HTTPS with your API key in the `Authorization` header. Only `openai-agents` needs to be installed. ### What does a run cost? A `small` sandbox is $0.018/hour and a `default` is $0.036/hour, billed per second while running. A stopped sandbox costs nothing. Model usage is billed by OpenAI on your own key. ## Related - [OpenAI Agents SDK](/docs/openai-agents) - [MCP server](/docs/mcp) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Python SDK](/docs/sdk-python) --- # Claude Agent SDK Inside the Sandbox, or the Sandbox as a Tool? Source: https://sandboxer.com/guides/claude-agent-sdk-inside-the-sandbox-or-as-a-tool. Updated 2026-10-27. **Short answer:** Run the agent inside the sandbox when its built-in Bash and file tools should only ever touch a disposable VM and it works for long stretches alone. Keep the agent on your server and give it Sandboxer as an MCP tool when your keys and app state must stay with you and only code execution needs isolating. ## Why the choice matters The [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview) is a library that runs the Claude Code binary inside your program. Anthropic's [hosting guide](https://code.claude.com/docs/en/agent-sdk/hosting) explains that each `query()` starts a `claude` subprocess that "owns the shell, the working directory, and the JSONL session transcripts on local disk." Its built-in tools (Bash, Read, Edit, Write) act on whatever machine that subprocess runs on. So the real question is which machine that is: - **Setup A, agent inside the sandbox.** Your program and its `claude` subprocess run in a Sandboxer VM. Every built-in tool touches only that VM. - **Setup B, sandbox as a tool.** Your program runs on your server. It reaches a Sandboxer VM through the [MCP server](/docs/mcp), and you turn off the built-in Bash so commands don't run on your server. ## The decision | | A: agent inside the sandbox | B: agent on your server, sandbox as a tool | | - | - | - | | Where the agent loop runs | In the VM | On your server | | Where `ANTHROPIC_API_KEY` lives | In the VM's environment | On your server only | | Where the Sandboxer key lives | On the machine that runs `sbx` (the VM gets none with `--no-env`) | On your server, in the MCP config's header or `env` | | What a bad command can reach | The VM: its files, its environment, its network | The VM, through MCP tools. Built-in tools you leave on still act on your server | | Tool-call latency | Built-in tools run locally in the VM. Only model calls leave it | Each tool call is a network round trip to Sandboxer: about 90 ms per hosted MCP call in our test (below) | | Startup | `sbx new` took 3.2 s in our test (October 27, 2026), plus a one-time SDK install (about 340 MB with its bundled binary) | SDK init with both Sandboxer servers connected took 437–518 ms. The agent creates a VM (`create_sandbox`) when it needs one | | Sandbox cost | The VM bills for the whole session, model time included | The VM bills from `create_sandbox` until `stop_sandbox` or its TTL | | Session files and memory | In the VM. Stopping it keeps them; deleting it loses them | On your server, next to your app's own state | | Best for | Long autonomous tasks, coding agents, anything you'd run with permission prompts off | Chat apps and multi-tenant services where the agent mostly talks and sometimes runs code | Sandbox cost is small next to the model in both setups. `small` (2 vCPU, 4 GB) is $0.018 an hour and `default` (4 vCPU, 8 GB) is $0.036 an hour, billed per second. Anthropic's hosting guide notes that token cost "typically dominates container infrastructure cost by an order of magnitude or more." ## Setup A: the agent runs inside the sandbox Create a VM. `--no-env` keeps your account's secrets out of it, so the agent can't act on your Sandboxer account: ```bash sbx new --type small --no-env ``` In our single run on October 27, 2026, it was ready in 3.2 s. Every sandbox has Node, Python and Claude Code preinstalled: ```bash sbx exec sb_lw7iz00qux "node --version; npm --version; python3 --version; claude --version" ``` ```text v22.23.3 10.9.9 Python 3.12.3 2.1.301 (Claude Code) ``` That's above Anthropic's [stated minimums](https://code.claude.com/docs/en/agent-sdk/quickstart) (Node.js 18+ or Python 3.10+). Install the SDK in the VM and check that it imports: ```bash sbx exec sb_lw7iz00qux --timeout 300 'mkdir -p agent && cd agent && npm init -y >/dev/null && npm pkg set type=module && npm i @anthropic-ai/claude-agent-sdk && node -e "import(\"@anthropic-ai/claude-agent-sdk\").then(m => console.log(typeof m.query, typeof m.tool, typeof m.createSdkMcpServer))"' ``` ```text added 105 packages, and audited 106 packages in 10s … found 0 vulnerabilities function function function ``` `query`, `tool` and `createSdkMcpServer` all imported (package version 0.3.311). For Python, use a virtual environment: ```bash sbx exec sb_lw7iz00qux --timeout 300 'cd agent && python3 -m venv .venv && .venv/bin/pip install -q claude-agent-sdk && .venv/bin/python -c "import claude_agent_sdk as s; print(s.__version__, s.query.__name__, s.ClaudeAgentOptions.__name__)"' ``` ```text 0.2.174 query ClaudeAgentOptions ``` Both SDKs bundle their own Claude Code binary, per Anthropic's quickstart. That's why the installs are large: `node_modules` was 342 MB and the venv 338 MB. Install once, then save the VM as a template so later VMs start with it. See [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template). Here is a small agent that uses the built-in tools, which now act only on the VM: ```javascript // agent.mjs import { query } from "@anthropic-ai/claude-agent-sdk"; const prompt = process.argv[2] ?? "Run the tests in this repo and fix what fails."; for await (const message of query({ prompt, options: { cwd: "/home/user/agent", allowedTools: ["Bash", "Read", "Edit", "Write", "Glob", "Grep"], permissionMode: "acceptEdits", maxTurns: 30, }, })) { if (message.type === "system" && message.subtype === "init") { console.log("init: model", message.model, "tools", message.tools.length); } else if (message.type === "result") { console.log("result:", message.subtype, message.result ?? ""); } } ``` Copy it in and start it in the background, so it keeps running after your terminal closes: ```bash sbx scp ./agent.mjs sb_lw7iz00qux:/home/user/agent/ sbx exec sb_lw7iz00qux --detach --cwd agent 'node agent.mjs "Say hello"' ``` ```text {"ok":true,"type":"command.started","command":"node agent.mjs \"Say hello\"","commandId":"cmd_3xaqnt6wzzqb",…,"processId":630,…} ``` Read its output any time, even after the sandbox stops: ```bash sbx history sb_lw7iz00qux cmd_3xaqnt6wzzqb ``` ```text init: model claude-opus-5-5 tools 25 result: success Not logged in · Please run /login ``` We ran this with no Anthropic key in the VM, so the agent started, loaded its 25 built-in tools and stopped at sign-in. No model call ran. To run it for real, give the VM your key. Keys saved on the dashboard's **Agents** page never reach a `--no-env` sandbox, so pass it to that one sandbox with `sbx new --no-env -e ANTHROPIC_API_KEY=…`. Anthropic's quickstart says the SDK reads the key from the environment and doesn't load `.env` files. If you don't need your own program at all, the [integrated agents](/docs/agents) run Claude Code in the VM from one `sbx prompt` call or API request. ## Setup B: the agent runs on your server and calls the sandbox Point the SDK's `mcpServers` option at Sandboxer. Anthropic's [MCP guide](https://code.claude.com/docs/en/agent-sdk/mcp) uses `type: "http"` with `headers` for remote servers and `command`/`args`/`env` for stdio servers. This config sets up both Sandboxer servers, the hosted one and the local `sbx mcp`. In practice you'd pick one: ```javascript // agent.mjs, on your server import { query } from "@anthropic-ai/claude-agent-sdk"; const q = query({ prompt: "List my sandboxes.", options: { mcpServers: { sandboxer: { type: "http", url: "https://sandboxer.com/mcp", headers: { Authorization: `Bearer ${process.env.SANDBOXER_API_KEY}` }, }, "sandboxer-local": { type: "stdio", command: "sbx", args: ["mcp", "--type", "small", "--ttl", "1800"], env: { SANDBOXER_API_KEY: process.env.SANDBOXER_API_KEY }, }, }, allowedTools: ["mcp__sandboxer__*", "mcp__sandboxer-local__*"], disallowedTools: ["Bash"], maxTurns: 1, }, }); try { for await (const message of q) { if (message.type === "system" && message.subtype === "init") { console.log("servers:", message.mcp_servers); const tools = message.tools.filter((t) => t.startsWith("mcp__")); console.log(`${tools.length} MCP tools, e.g.`, tools.slice(0, 3)); } else if (message.type === "result") { console.log("result:", message.subtype, message.result ?? ""); } } } catch (err) { console.log("ended:", String(err).split("\n")[0]); } ``` ```bash node agent.mjs ``` ```text servers: [ { name: 'sandboxer', status: 'connected', source: 'dynamic' }, { name: 'sandboxer-local', status: 'connected', source: 'dynamic' } ] 37 MCP tools, e.g. [ 'mcp__sandboxer__command_status', 'mcp__sandboxer__create_sandbox', 'mcp__sandboxer__delete_sandbox' ] result: success Not logged in · Please run /login ended: Error: Claude Code returned an error result: Not logged in · Please run /login ``` Both servers connected: 18 tools from the hosted server and 19 from `sbx mcp`, which adds `handoff_session`. As in Setup A, there was no Anthropic key, so the run stopped at sign-in, before any model call. Over three runs, the init message arrived 437–518 ms after `query()` started. Notes on this config: - **Use a key, not browser sign-in.** Anthropic's MCP guide says the SDK "doesn't open a browser or run an interactive OAuth flow," so send a Sandboxer API key in `headers`. A [scoped key](/docs/api-keys) limits what the agent can do. - **Turn off the built-in tools you don't want on your server.** `disallowedTools: ["Bash"]` stops shell commands from running on your server. Add `Write` and `Edit` if the agent shouldn't change files there either. - **`allowedTools` with `mcp__sandboxer__*` lets the agent call every Sandboxer tool without a prompt.** Tool names follow Anthropic's `mcp____` pattern. List single tools instead, such as `mcp__sandboxer__exec`, to allow less. - **Cap what the agent creates.** `sbx mcp --type small --ttl 1800` makes every sandbox the agent creates `small`, stopping after 30 minutes unless the agent passes its own values. ### Check the Sandboxer side yourself You can call the hosted server without the SDK. The key goes in through stdin, so it never shows up in your shell history or process list: ```bash printf 'Authorization: Bearer %s\n' "$SANDBOXER_API_KEY" | curl -sS -D headers.txt -H @- \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ https://sandboxer.com/mcp ``` ```text {"id":1,"jsonrpc":"2.0","result":{"capabilities":{"tools":{"listChanged":false}},"instructions":"Tools for Sandboxer cloud sandboxes: Ubuntu microVMs with root, Docker and a public HTTPS URL per port.\n…","protocolVersion":"2025-06-18","serverInfo":{"name":"sandboxer","title":"Sandboxer",…}}} ``` The response carries an `mcp-session-id` header. Send it back as `Mcp-Session-Id` with `notifications/initialized` (HTTP 202), then `tools/list`. That returned 18 tools, from `create_sandbox` to `delete_snapshot`, in 116 ms. Each `initialize` took 89–91 ms and each `list_sandboxes` call took 91 ms. The stdio server answers the same handshake on stdin: ```bash printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | sbx mcp --type small --ttl 1800 ``` It returned `serverInfo` `sandboxer` and 19 tools. ## Mixing the two The setups combine well. A server-side agent (B) can create a sandbox, copy a second Agent SDK program into it and start that with `exec` and `detached: true`. That inner agent (A) then works on its own with the Anthropic key you gave that VM. Your orchestrator holds the Sandboxer key, and each worker VM holds only the Anthropic key it needs. To run several workers side by side, see [Run coding agents in parallel](/guides/run-coding-agents-in-parallel). ## FAQ ### Does the Claude Agent SDK need Claude Code installed in the sandbox? No. Anthropic's quickstart says both SDKs bundle a native Claude Code binary for most installs. Sandboxer VMs also have `claude` preinstalled (2.1.301 in our test), which is handy for `claude -p` or an interactive session over SSH. ### Can I use my Claude subscription with an Agent SDK agent? Anthropic's docs say third-party developers can't offer claude.ai login or rate limits in products built on the Agent SDK unless Anthropic has approved it. Use an API key, or Bedrock, Vertex or Foundry credentials as Anthropic's quickstart describes. ### Which setup is faster? For tool-heavy tasks, Setup A. Each Bash or file call runs inside the VM, while in Setup B every tool call is a network round trip (about 90 ms per hosted MCP call in our test). Model calls cost the same in both, and they usually take far longer than either. ### Is it safe to run the agent with permission prompts off? In Setup A, the agent can only reach what's in that VM, so start it with `--no-env` and only the keys the task needs. See [Claude Code without permission prompts](/guides/claude-code-skip-permissions-safely). ### Do I need the CLI on my server for Setup B? No. The hosted server at `https://sandboxer.com/mcp` needs only an API key in `headers`. Use `sbx mcp` if you want its `--type` and `--ttl` defaults or its `handoff_session` tool. See [Local or hosted MCP](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex). ## Related - [Claude Agent SDK docs](/docs/claude-agent-sdk) - [MCP server](/docs/mcp) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) - [Local or hosted MCP for Cursor, Claude Code and Codex](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex) --- # Give a CrewAI Agent a Code Interpreter That Runs in a MicroVM Source: https://sandboxer.com/guides/crewai-code-interpreter-in-a-microvm. Updated 2026-10-28. **Short answer:** Write a CrewAI BaseTool whose _run sends the agent's Python to a Sandboxer microVM through sandboxer-sdk, then pass it in the agent's tools list. The sandbox starts on the first call and keeps files and packages between calls. CrewAI's own CodeInterpreterTool has been removed, and CrewAI now points you to a sandbox service. ## What changed in CrewAI CrewAI used to ship a built-in `CodeInterpreterTool`. Per [CrewAI's documentation](https://docs.crewai.com/en/tools/ai-ml/codeinterpretertool), it ran code in a Docker container (image `code-interpreter:latest`), fell back to a restricted Python environment when Docker wasn't available, and had an `unsafe_mode` that ran code directly on the host. The same page notes that the container can see the current working directory. That page now says the tool has been removed from `crewai-tools`, that the agent's `allow_code_execution` and `code_execution_mode` settings are deprecated, and that you should use a dedicated sandbox service instead. We checked in `crewai` 1.15.30: the `Agent` fields `allow_code_execution` and `code_execution_mode` describe themselves as "Deprecated. CodeInterpreterTool is no longer available." | | Old `CodeInterpreterTool` | The tool on this page | | - | - | - | | Where code runs | Docker on your machine, or a restricted Python, or the host in `unsafe_mode` | A Sandboxer microVM with its own kernel | | What you install locally | Docker | Nothing beyond `crewai` and `sandboxer-sdk` | | Can it see your files? | The container can see the working directory | No; only what you write into the sandbox | | Packages | `libraries_used`; not allowed in the restricted fallback | `libraries_used`, installed with `pip` in the VM | | State between calls | Not covered in its docs | Files and packages stay until you delete the sandbox; a stopped one is kept for 14 days after it last stopped | | Status in current CrewAI | Removed | A custom `BaseTool`, the documented extension point | ## Install We used Python 3.12, `crewai` 1.15.30 and `sandboxer-sdk` 0.1.1. ```bash python3.12 -m venv venv && . venv/bin/activate pip install crewai sandboxer-sdk export SANDBOXER_API_KEY=sbx_live_… ``` Install `sandboxer-sdk`, not `sandboxer`: that PyPI name belongs to an unrelated project. The package imports as `sandboxer`. ## The tool This follows CrewAI's [custom tool pattern](https://docs.crewai.com/en/learn/create-custom-tools): a `BaseTool` subclass with a pydantic `args_schema` and a `_run` method. The argument names match the old tool's (`code`, `libraries_used`), so prompts written for it still fit. ```python title="sandbox_tool.py" import shlex import uuid from typing import List, Optional, Type from crewai.tools import BaseTool from pydantic import BaseModel, Field, PrivateAttr from sandboxer import Sandbox, Sandboxer class RunPythonInput(BaseModel): code: str = Field(..., description="Python 3 code to run. Print what you want to see.") libraries_used: List[str] = Field( default_factory=list, description="PyPI packages the code needs, installed with pip before it runs.", ) class SandboxerCodeInterpreterTool(BaseTool): name: str = "sandboxer_code_interpreter" description: str = ( "Runs Python 3 code in an isolated cloud microVM (Ubuntu). " "Files and installed packages persist between calls. Returns exit code, stdout and stderr." ) args_schema: Type[BaseModel] = RunPythonInput sandbox_name: str = "crewai-code" sandbox_type: str = "small" ttl_seconds: int = 1800 timeout_seconds: int = 300 _client: Optional[Sandboxer] = PrivateAttr(default=None) _sandbox: Optional[Sandbox] = PrivateAttr(default=None) def _get_sandbox(self) -> Sandbox: if self._sandbox is None: self._client = Sandboxer() # reads SANDBOXER_API_KEY self._sandbox = self._client.create( type=self.sandbox_type, ttl_seconds=self.ttl_seconds, name=self.sandbox_name ) # waits until ready return self._sandbox def _run(self, code: str, libraries_used: Optional[List[str]] = None) -> str: sandbox = self._get_sandbox() if libraries_used: pkgs = " ".join(shlex.quote(p) for p in libraries_used) install = sandbox.exec( f"pip install -q --break-system-packages {pkgs}", timeout_seconds=self.timeout_seconds ) if install.exit_code != 0: return f"pip install failed (exit {install.exit_code})\n{install.stderr[-2000:]}" path = f"crewai/{uuid.uuid4().hex[:8]}.py" sandbox.files.write(path, code) result = sandbox.exec(["python3", path], timeout_seconds=self.timeout_seconds) out = f"exit code {result.exit_code}\n{result.stdout}" if result.stderr: out += f"\nstderr:\n{result.stderr}" return out[-8000:] def close(self, delete: bool = False) -> None: if self._sandbox is not None: self._sandbox.delete() if delete else self._sandbox.stop() self._sandbox = None ``` A few choices worth knowing: - The code goes in as a file (`files.write`) and runs as `python3 `, so it is never pasted into a shell command. - A non-zero exit is returned to the agent as text, not raised. The model sees the traceback and can fix its code. - `ttl_seconds=1800` makes the sandbox stop itself after 30 minutes if your program never calls `close()`. - The output is cut to the last 8,000 characters so a noisy script doesn't flood the model's context. ## Call it directly This is the part we ran on a real `small` sandbox, without a model: three calls to the tool, the way CrewAI would make them. ```python tool = SandboxerCodeInterpreterTool() try: print(tool._run(code="import platform, os\nprint(platform.platform())\nprint('user', os.getuid())\nopen('notes.txt','w').write('42')")) print(tool.run(code="import os, humanize\nprint(open('notes.txt').read())\nprint(humanize.naturalsize(os.path.getsize('/usr/bin/python3')))", libraries_used=["humanize"])) print(tool.run(code="1/0")) finally: tool.close(delete=True) ``` ```text exit code 0 Linux-6.18.56-sandboxer-x86_64-with-glibc2.39 user 1000 exit code 0 42 8.0 MB exit code 1 stderr: Traceback (most recent call last): File "/home/user/crewai/e1650c80.py", line 1, in 1/0 ~^~ ZeroDivisionError: division by zero ``` In our single run on October 27, 2026, the first call took 4.7 seconds, including creating the sandbox. The second, which installed `humanize` and read the file the first call wrote, took 1.9 seconds. The kernel line shows the code ran in the VM, not on the machine running CrewAI. ## Attach it to an agent Pass the tool in `tools=[...]` as in [CrewAI's tools docs](https://docs.crewai.com/en/concepts/tools): ```python from crewai import Agent, Crew, Task from sandbox_tool import SandboxerCodeInterpreterTool code_tool = SandboxerCodeInterpreterTool(sandbox_name="crewai-analyst") analyst = Agent( role="Data analyst", goal="Answer questions by writing and running Python", backstory="You check every number by running code.", tools=[code_tool], ) task = Task( description="Compute the 30th Fibonacci number and the sum of primes below 10,000.", expected_output="Both numbers, with the code you ran.", agent=analyst, ) crew = Crew(agents=[analyst], tasks=[task]) try: print(crew.kickoff()) finally: code_tool.close() # stop: snapshot the disk and stop billing ``` We built this agent, task and crew with `crewai` 1.15.30 and confirmed the tool is registered, and that no sandbox is created until the tool is first called. We did not run `crew.kickoff()`, because it needs a model API key. So there's no model transcript here. The tool calls the model would make are the ones shown above. ## Share one sandbox or give each agent its own One tool instance means one sandbox. Give two agents the same instance and they share files: one can write `data.csv` and the other can read it. Give each agent its own instance, with its own `sandbox_name`, when their work must stay apart. For a crew that runs again later, keep `tool._sandbox.id`, and next time reopen it with `Sandboxer().get(sandbox_id)`, calling `resume()` first if it has stopped. The files and installed packages are still there. ## FAQ ### Do I need Docker for this? No. The code runs in a Sandboxer microVM in the cloud, so the machine running CrewAI only needs Python and the two packages. ### What does a sandbox cost while the crew runs? A `small` sandbox is $0.018/hour, billed per second while it runs. A stopped sandbox costs nothing, so call `close()` when the crew finishes or let the TTL stop it. ### Can the agent install packages? Yes. List them in `libraries_used` and the tool installs them with `pip` in the VM before it runs the code. They stay installed for later calls to the same sandbox. ### Can the agent run shell commands, not just Python? Not with this tool as written. Add a second `BaseTool` that calls `sandbox.exec(command)`, or connect the [Sandboxer MCP server](/docs/mcp) for the full tool set. ### Does it work with the async API? Yes. `sandboxer-sdk` has `AsyncSandboxer` with the same methods, and `BaseTool` has an `_arun` method you can implement with it. ## Related - [Python SDK](/docs/sdk-python) - [LangChain & LangGraph](/docs/langchain) - [MCP server](/docs/mcp) - [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code) --- # Give a LangGraph Agent a Python Sandbox Source: https://sandboxer.com/guides/give-a-langgraph-agent-a-python-sandbox. Updated 2026-10-27. **Short answer:** Write a LangGraph tool that sends the model's Python to a Sandboxer microVM with the Python SDK and returns stdout, stderr and the exit code. Key the sandbox by the graph's thread_id, so each conversation keeps its own files, give every run a timeout, and delete or stop the sandboxes when the graph is done. ## At a glance | Decision | What this guide does | Why | | - | - | - | | How code runs | A `run_python` tool: write `main.py`, run `python3 main.py` with `sandbox.exec` | The model writes a whole program per call, which is the CodeAct pattern | | Where it runs | A `small` Sandboxer sandbox: an Ubuntu 24.04 microVM with `python3`, `pip` and `uv` | The model's code never touches the process that runs your graph | | Sandbox per | LangGraph `thread_id` | Files from turn 1 are still there in turn 2; two users never share a disk | | Runaway code | `timeout_seconds` on every `exec` (1 to 600) | A `while True` loop costs seconds, not the whole turn | | Forgotten sandboxes | `ttl_seconds=1800` on create, `delete()` in `finally` | The sandbox stops itself even if your process crashes | ## What we ran, and what we didn't We have no LLM key on the test account, so the model in this guide is a stub: a graph node that always emits the same tool call, then copies the tool's answer into the reply. Everything after the model is real. The stub's tool call goes through LangGraph's `ToolNode` to the tool, the tool calls the Sandboxer API, and the program runs in a new microVM on production. We also called the tool directly, with no graph. The outputs below are from that run (`langgraph` 1.2.18, `langchain-core` 1.7.0, `sandboxer-sdk` 0.1.1, Python 3.12). To use a real model, swap the stub node for a chat model bound to `[run_python]`; nothing else changes. ## 1. Install ```bash python3 -m venv .venv && . .venv/bin/activate pip install langgraph langchain-core sandboxer-sdk export SANDBOXER_API_KEY=sbx_live_… ``` Install `sandboxer-sdk`, not `sandboxer`: that PyPI name belongs to an unrelated project. Give the graph a scoped key that can create sandboxes and delete only the ones it created: `sbx api-key create agent --preset ci`. ## 2. The tool and the graph Save this as `codeact.py`. ```python import os import threading from langchain_core.messages import AIMessage, ToolMessage from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import START, MessagesState, StateGraph from langgraph.prebuilt import ToolNode, tools_condition from sandboxer import Sandboxer client = Sandboxer() # reads SANDBOXER_API_KEY PREFIX = os.environ.get("SANDBOX_PREFIX", "langgraph-") _sandboxes = {} # thread_id -> Sandbox _lock = threading.Lock() def sandbox_for(thread_id: str): """One sandbox per LangGraph thread, created on first use.""" with _lock: if thread_id not in _sandboxes: _sandboxes[thread_id] = client.create( type="small", ttl_seconds=1800, # stops itself if this process dies name=f"{PREFIX}{thread_id}", ) return _sandboxes[thread_id] def close_all(): for thread_id, sandbox in _sandboxes.items(): sandbox.delete() # or sandbox.stop() to keep files for later print(f"deleted {sandbox.id} ({thread_id})") _sandboxes.clear() @tool def run_python(code: str, config: RunnableConfig) -> str: """Run a Python 3 program in this conversation's cloud sandbox and return its output. Files and installed packages persist between calls in the same conversation.""" sandbox = sandbox_for(config["configurable"]["thread_id"]) sandbox.files.write("main.py", code) r = sandbox.exec("python3 main.py", timeout_seconds=int(os.environ.get("RUN_TIMEOUT", "120"))) status = "timed out" if r.timed_out else f"exit code {r.exit_code}" out = (r.stdout + r.stderr)[-4000:] # keep the model's context small return f"[{sandbox.id}] {status}\n{out}".strip() # A stand-in for the model: it always asks for the same tool call, then reports the result. # Replace this node with a real chat model bound to [run_python]. PROGRAM = """ import json n = 1_000_000 sieve = bytearray([1]) * n sieve[0:2] = b"\\x00\\x00" for i in range(2, int(n ** 0.5) + 1): if sieve[i]: sieve[i * i::i] = bytearray(len(range(i * i, n, i))) primes = [i for i, p in enumerate(sieve) if p] gaps = [b - a for a, b in zip(primes, primes[1:])] json.dump(primes, open("primes.json", "w")) print("primes below 1,000,000:", len(primes)) print("largest gap:", max(gaps), "after", primes[gaps.index(max(gaps))]) """ def fake_model(state: MessagesState): last = state["messages"][-1] if isinstance(last, ToolMessage): return {"messages": [AIMessage(content=f"Tool said:\n{last.content}")]} code = PROGRAM if last.content.startswith("count the primes") else last.content return {"messages": [AIMessage(content="", tool_calls=[ {"name": "run_python", "args": {"code": code}, "id": f"call_{len(state['messages'])}"} ])]} builder = StateGraph(MessagesState) builder.add_node("agent", fake_model) builder.add_node("tools", ToolNode([run_python])) builder.add_edge(START, "agent") builder.add_conditional_edges("agent", tools_condition) builder.add_edge("tools", "agent") graph = builder.compile(checkpointer=InMemorySaver()) def ask(thread_id: str, text: str): config = {"configurable": {"thread_id": thread_id}} result = graph.invoke({"messages": [{"role": "user", "content": text}]}, config) print(f"--- thread {thread_id}: {result['messages'][-1].content}\n") if __name__ == "__main__": try: # 1. The tool on its own, no graph print(run_python.invoke( {"code": "import platform, os; print(platform.python_version(), os.getcwd())"}, config={"configurable": {"thread_id": "1"}}, ), "\n") # 2. Graph -> tool -> sandbox, same thread, so the same sandbox ask("1", "count the primes below 1,000,000") ask("1", "import json; print(len(json.load(open('primes.json'))), 'primes still on disk')") # 3. A second thread gets its own sandbox ask("2", "import os; print(os.path.exists('primes.json'))") # 4. A runaway program hits the timeout os.environ["RUN_TIMEOUT"] = "5" ask("2", "import time\nwhile True: time.sleep(1)") finally: close_all() ``` The model only sees one argument. `config` is the name LangChain reserves for passing the run's `RunnableConfig` into a tool, so it is left out of the tool's schema (`run_python.args` is `{'code': …}`), and `ToolNode` fills it in with the graph's `thread_id`. See LangChain's [tools guide](https://docs.langchain.com/oss/python/langchain/tools) for `@tool`, docstrings as descriptions and injected arguments, and LangGraph's [persistence guide](https://docs.langchain.com/oss/python/langgraph/persistence) for threads and checkpointers. ## 3. Run it ```bash python3 codeact.py ``` ```text [sb_g6jl6xmzhc] exit code 0 3.12.3 /home/user --- thread 1: Tool said: [sb_g6jl6xmzhc] exit code 0 primes below 1,000,000: 78498 largest gap: 114 after 492113 --- thread 1: Tool said: [sb_g6jl6xmzhc] exit code 0 78498 primes still on disk --- thread 2: Tool said: [sb_lqkqv4gsj5] exit code 0 False --- thread 2: Tool said: [sb_lqkqv4gsj5] timed out deleted sb_g6jl6xmzhc (1) deleted sb_lqkqv4gsj5 (2) ``` The whole script took about 12 seconds in our single run on October 27, 2026, including creating two sandboxes and deleting them. Reading the output: - The direct call created thread 1's sandbox. The code ran as Python 3.12.3 in `/home/user`, the sandbox's home directory. - The graph's first turn on thread 1 reused that sandbox (same id) and wrote `primes.json`. - The second turn on thread 1 read `primes.json` back. Files persist across turns of a thread. - Thread 2 got a different sandbox, where `primes.json` doesn't exist. - The infinite loop returned `timed out` after the 5-second limit instead of hanging the graph. ## One sandbox per thread Keying sandboxes by `thread_id` matches how LangGraph already scopes a conversation. In a server that handles many users, every user's thread gets its own VM, so one user's code can't read another's files. The in-memory dict above lives as long as the process. For a graph that pauses for hours or across restarts, store `sandbox.id` in the graph state or next to your checkpointer, reopen it with `client.get(sandbox_id)`, and call `resume()` first if it has stopped. The disk and installed packages are still there. The [LangChain and LangGraph docs page](/docs/langchain) shows the same pattern. If you'd rather not use the reserved `config` name, LangGraph's `ToolRuntime` works too: add `runtime: ToolRuntime` (from `langgraph.prebuilt`) to the tool's signature and read `runtime.execution_info.thread_id`. We checked that it returns the thread id inside a graph. A tool written that way can't be called on its own without building a runtime, which is why this guide uses `config`. ## Timeouts There are two clocks, and you want both: - **Per call:** `exec(..., timeout_seconds=N)` waits at most N seconds (1 to 600, default 30) and sets `timed_out` on the result. Tell the model in the tool output that it timed out, so it can try a cheaper approach. - **Per sandbox:** `ttl_seconds` on create stops the sandbox after that long (default one hour). This is the safety net for a process that dies before `finally` runs. For work longer than 10 minutes, start it detached with `sandbox.start("python3 main.py")` and give the model a second tool that checks `command_status`. The [Python SDK docs](/docs/sdk-python) cover detached commands and streaming. ## Cleanup Pick one per use case: | Call | Files kept | Billing | Use it when | | - | - | - | - | | `sandbox.delete()` | No | Ends | Throwaway runs, tests, one-shot jobs | | `sandbox.stop()` | Yes, and memory | Ends while stopped | The thread will continue later | | Neither (TTL only) | Yes, after auto-stop | Until the TTL fires | Never on purpose; it's the fallback | Call it in a `finally` block, as the script does, or when your app ends a session. After the run above, `sbx list` showed none of the run's sandboxes, and both ids returned 404. ## FAQ ### Does this work with a real model? Yes. Replace `fake_model` with a node that calls a chat model bound to `[run_python]` (for example `model.bind_tools([run_python])`). The tool, the sandbox handling and the graph edges stay the same. We tested only the stub path, since we had no LLM key. ### Can the agent install packages? Yes. The sandbox runs as a user with root through `sudo`, and `pip` and `uv` are installed. A package the agent installs in one turn is still there in the next turn of the same thread. ### Why not run the code with exec() in my own process? The model's code would then run with your process's files, network and credentials. In a sandbox it runs in a separate microVM, and the worst case is a broken sandbox you delete. ### How do I get a chart or file back? Have the program save it, then read it with `sandbox.files.read_bytes("plot.png")` (up to 5 MiB) or `sandbox.files.download(...)` for larger files (up to 50 MiB). ## Related - [LangChain and LangGraph](/docs/langchain) - [Python SDK](/docs/sdk-python) - [MCP server](/docs/mcp) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) --- # How to Run Your Daytona SDK Code on Sandboxer Source: https://sandboxer.com/guides/run-daytona-sdk-code-on-sandboxer. Updated 2026-10-29. **Short answer:** Keep the official Daytona SDK and set two variables, DAYTONA_API_URL to https://sandboxer.com/daytona/api and DAYTONA_API_KEY to a Sandboxer API key. We ran create, commands, files, sessions, previews, stop, start and delete this way with the Python and TypeScript SDKs on prod. Calls the docs list as not covered raise not_supported. This page is a walkthrough you can copy. Every script below ran on prod on 2026-10-29, with `daytona` 0.226.1 for Python (3.12) and `@daytonaio/sdk` 0.226.1 for TypeScript (Node 24). The list of covered and refused calls lives in the [Daytona-compatible API reference](/docs/daytona). For a step-by-step move of a whole project, including images, volumes and secrets, see the [Daytona migration guide](/migrate/daytona). ## Step 1: Install the SDK you already use Nothing here is Sandboxer-specific. Install Daytona's own packages. ```bash # Python pip install daytona # TypeScript / JavaScript npm i @daytonaio/sdk ``` ## Step 2: Point it at Sandboxer Create a Sandboxer API key in the dashboard or with `sbx api-key create` ([API keys](/docs/api-keys)). Its `ci` preset covers create, run, files, previews and deleting what the key created. Then set two variables in the shell, CI job or container where your code runs: ```bash export DAYTONA_API_URL=https://sandboxer.com/daytona/api export DAYTONA_API_KEY=sbx_live_… # the Sandboxer key from above ``` `Daytona()` and `new Daytona()` read both variables, so your code needs no constructor arguments. The SDK finds the toolbox URL by itself. ## Step 3: Run a full lifecycle in Python This script creates a sandbox in the EU region, runs a command, round-trips a file, uses a session, opens a preview link, stops and starts the sandbox, and deletes it. `target="eu"` picks the region; `us` works too, as do `us-east` and `eu-central` ([regions](/docs/regions)). Leave `target` out to get the region nearest you. ```python import time, urllib.request, urllib.error from daytona import Daytona, CreateSandboxFromSnapshotParams, SessionExecuteRequest daytona = Daytona() # reads DAYTONA_API_URL and DAYTONA_API_KEY # 1. Create in a chosen region sandbox = daytona.create(CreateSandboxFromSnapshotParams( name="aeo-daytona-sdk-1", target="eu", labels={"purpose": "aeo-daytona-sdk"})) print("created", sandbox.id, sandbox.state, sandbox.target, f"{sandbox.cpu} vCPU / {sandbox.memory} GB") try: # 2. Run a command r = sandbox.process.exec("uname -sr && pwd") print("exec:", r.exit_code, r.result.strip().replace("\n", " | ")) # 3. Upload and download a file sandbox.fs.upload_file(b"name,score\nada,91\nlin,87\n", "/home/user/scores.csv") print("download:", sandbox.fs.download_file("/home/user/scores.csv").decode().splitlines()) # 4. A session keeps cwd and exported variables sandbox.process.create_session("work") sandbox.process.execute_session_command("work", SessionExecuteRequest(command="cd /home/user && export RUN=42")) s = sandbox.process.execute_session_command("work", SessionExecuteRequest(command='echo "run $RUN in $(pwd)"')) print("session:", s.exit_code, s.output.strip()) # 5. A preview link for a port sandbox.process.exec("nohup python3 -m http.server 8000 --directory /home/user >/dev/null 2>&1 &") link = sandbox.get_preview_link(8000) print("preview:", link.url, "token length", len(link.token)) def status(headers): req = urllib.request.Request(link.url + "/scores.csv", headers=headers) try: return urllib.request.urlopen(req).status except urllib.error.HTTPError as e: return e.code for _ in range(10): code = status({"x-daytona-preview-token": link.token}) if code == 200: break time.sleep(1) print("preview with token:", code, "without:", status({})) # 6. Stop and start t = time.time(); sandbox.stop(); print("stopped", sandbox.state, f"{time.time()-t:.1f}s") t = time.time(); sandbox.start(); print("started", sandbox.state, f"{time.time()-t:.1f}s") print("after start:", sandbox.process.exec("cat /home/user/scores.csv | wc -l; pgrep -f http.server >/dev/null && echo server still running").result.strip().replace("\n", " | ")) finally: sandbox.delete() print("deleted", sandbox.id) ``` Output, with the token never printed: ```text created sb_vvauj4ddgd SandboxState.STARTED eu-central 2 vCPU / 4 GB exec: 0 Linux 6.18.56-sandboxer | /home/user download: ['name,score', 'ada,91', 'lin,87'] session: 0 run 42 in /home/user preview: https://modern-basic-newt-8000.box.sandboxer.com token length 32 preview with token: 200 without: 401 stopped SandboxState.STOPPED 1.1s started SandboxState.STARTED 1.1s after start: 3 | server still running deleted sb_vvauj4ddgd ``` What to read from this run: - **The id is a Sandboxer id** (`sb_…`). The guest user is `user`, with its home at `/home/user`. - **The size is `small`** (2 vCPU, 4 GB), because the SDK sent no size. Step 4 shows how to pick one. - **The preview needs its token.** Send it in the `x-daytona-preview-token` header. Without it the URL answers `401`. A new route can answer `404` for a second or two, which is why the script retries. Treat the token like a password and don't log it. - **The web server survived stop and start.** A stop saves memory and disk, so `start()` resumes the same processes. For the exceptions and the full concept map, see the [Daytona migration guide](/migrate/daytona). ## Step 4: Pick a size, and see a refused call, in TypeScript Without a size you get `small`. To get a bigger [Sandboxer size](/docs/machines), add the label `sandboxer.type` (`small`, `default`, `large` or `xlarge`). This script also sets `networkBlockAll`, which the docs list as not covered, to show what a refused call looks like. ```typescript import { Daytona } from '@daytonaio/sdk' const daytona = new Daytona() // reads DAYTONA_API_URL and DAYTONA_API_KEY // A field the docs list as not covered: the call fails before anything is created try { await daytona.create({ name: 'aeo-daytona-sdk-x', networkBlockAll: true }) } catch (e) { console.log('refused:', e.constructor.name, e.statusCode, e.code, '-', e.message) } const sandbox = await daytona.create({ name: 'aeo-daytona-sdk-3', labels: { 'sandboxer.type': 'default' } }) console.log('created', sandbox.id, sandbox.state, sandbox.target, `${sandbox.cpu} vCPU / ${sandbox.memory} GB`) const r = await sandbox.process.executeCommand('nproc && python3 --version') console.log('exec:', r.exitCode, r.result.trim().split('\n').join(' | ')) await sandbox.fs.uploadFile(Buffer.from('console.log(6 * 7)\n'), '/home/user/answer.js') const run = await sandbox.process.executeCommand('node answer.js', '/home/user') console.log('node:', run.exitCode, run.result.trim()) const back = await sandbox.fs.downloadFile('/home/user/answer.js') console.log('download:', back.toString().trim()) const link = await sandbox.getPreviewLink(3000) console.log('preview:', link.url) ``` Saved as `create.mjs` and run with `node create.mjs`: ```text refused: DaytonaValidationError 400 not_supported - networkBlockAll isn't supported by Sandboxer yet. created sb_7jv49bgrbq started us-east 4 vCPU / 8 GB exec: 0 4 | Python 3.12.3 node: 0 42 download: console.log(6 * 7) preview: https://ideal-plucky-mesa-3000.box.sandboxer.com ``` The refused create raised the SDK's normal error class, with `code` set to `not_supported` and a message that ends in "isn't supported by Sandboxer yet". No sandbox was created for it. The second create had no `target`, so it went to the region nearest the caller, here `us-east`. The `sandboxer.type` label gave 4 vCPU and 8 GB. To find every refused call in your own code, run your test suite against Sandboxer and search the failures for `not_supported`. ## Step 5: See the same sandbox with sbx The Daytona API and the `sbx` CLI share one account. With the TypeScript sandbox still running, `sbx list` showed it next to the account's other sandboxes: ```bash sbx list ``` ```text ID STATE TYPE IP AUTO-STOP CREATED NAME sb_7jv49bgrbq ready default 2605:6440:d000:bb::5b00:17 in 15m00s just now aeo-daytona-sdk-3 … ``` `AUTO-STOP` shows 15 minutes, the default `autoStopInterval`. Every toolbox call pushes it back. The same `sb_…` id works with every `sbx` command, such as `sbx exec`, `sbx scp` or `sbx info`. ## Step 6: Clean up by id Delete from the Daytona SDK with `daytona.get(id)`, then confirm with `sbx`: ```typescript import { Daytona } from '@daytonaio/sdk' const daytona = new Daytona() const sandbox = await daytona.get(process.argv[2]) console.log('got', sandbox.id, sandbox.name, sandbox.state) await sandbox.delete() console.log('deleted', sandbox.id) ``` ```bash node cleanup.mjs sb_7jv49bgrbq sbx info sb_7jv49bgrbq ``` ```text got sb_7jv49bgrbq aeo-daytona-sdk-3 started deleted sb_7jv49bgrbq {"event":"error","error":"Sandbox not found","code":"not_found","status":404,…} ``` `sbx delete --yes` deletes the same sandbox from the CLI. To sweep up sandboxes a crashed script left behind, see [clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes). ## Checklist before you switch a real project | Your code uses | What to do | | - | - | | `create()` with no size | You get `small`. Add the label `sandboxer.type` for more | | `target` or `DAYTONA_TARGET` | Use `us`, `eu`, `us-east` or `eu-central`, or leave it unset | | `exec`, `codeRun`, sessions, files, previews, stop, start, delete | Covered. See the [reference](/docs/daytona#whats-covered) | | A custom image or snapshot, volumes, GPUs, network controls | Raises `not_supported`. See the [reference](/docs/daytona#not-covered-yet) | | git, LSP, PTY, computer use in the toolbox | Raises `not_supported` | | `list()` filtered by state, or sorted | Raises `not_supported`. Filter by id, name or labels | ## FAQ ### Which Daytona SDK versions did you test? `daytona` 0.226.1 on Python 3.12 and `@daytonaio/sdk` 0.226.1 on Node 24, on 2026-10-29. The Go, Ruby and Java SDKs call the same API, but we haven't tested them. ### Do I need a Daytona account? No. `DAYTONA_API_KEY` holds a Sandboxer API key, and usage is billed to your Sandboxer account like any other sandbox. ### What happens when my code calls something that isn't covered? The SDK raises its usual error. The HTTP status is `400` or `501`, `code` is `not_supported`, and the message ends in "isn't supported by Sandboxer yet". Nothing is silently skipped. ### Can I mix the Daytona SDK and sbx on one sandbox? Yes. Both use the same `sb_…` id, so you can create with one and run, copy files or delete with the other. ## Related - [Daytona-compatible API reference](/docs/daytona) - [Migrate from Daytona to Sandboxer](/migrate/daytona) - [Regions](/docs/regions) - [API keys](/docs/api-keys) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # Build a Data-Analysis Agent That Runs Pandas in a Sandbox Source: https://sandboxer.com/guides/build-a-data-analysis-agent-with-a-sandbox. Updated 2026-10-27. **Short answer:** Give the agent one tool that runs Python in a Sandboxer microVM. Upload the CSV with the Python SDK, install pandas and matplotlib once per sandbox, let the tool run the model's code, then pull each chart PNG back with files.read_bytes and return the printed table. Reuse the sandbox for follow-up questions. ## At a glance | Step | Call | Measured in our run (single run, October 27, 2026) | | - | - | - | | Create a `small` sandbox (2 vCPU, 4 GB) | `client.create(type="small", ttl_seconds=1800)` | Part of an 11.5 s end-to-end script | | Install pandas and matplotlib, once | `uv pip install` into `~/.venv` with `sandbox.exec` | 1.1 s | | Upload the CSV (82 KB) | `sandbox.files.write("data/sales.csv", bytes)` | | | First question: table and chart | the `run_analysis` tool | 5.5 s, chart 88,207 bytes, 960 x 540 | | Follow-up question, same sandbox | the `run_analysis` tool again | 1.3 s, no reinstall | | New sandbox with the stack already installed | `client.create(from_="")` | Ready in 2.6 s | ## What we ran, and what we didn't We have no LLM key on the test account, so no model wrote the analysis code here. We wrote the code a model would send for "Which region grew the most? Show me a chart" and called the tool function directly. Everything after that is real: the CSV upload, the install, the pandas run in a microVM on production, the PNG download and the cleanup. Versions: `sandboxer-sdk` 0.1.1 on your side; pandas 3.1.0 and matplotlib 3.11.2 inside the sandbox. To use a real model, register `run_analysis` as a tool in your framework (its docstring is the description) and pass the model's `code` argument through. ## 1. Install and make a dataset ```bash python3 -m venv .venv && . .venv/bin/activate pip install sandboxer-sdk export SANDBOXER_API_KEY=sbx_live_… ``` Install `sandboxer-sdk`, not `sandboxer`: that PyPI name belongs to an unrelated project. Your machine doesn't need pandas. Only the sandbox does. We generated nine months of synthetic orders for four regions with this script (`make_data.py`): ```python import csv, random from datetime import date, timedelta random.seed(7) regions = {"North": 1.00, "South": 0.85, "East": 1.10, "West": 0.95} growth = {"North": 0.010, "South": 0.035, "East": 0.005, "West": 0.020} # monthly products = {"Starter": 19.0, "Team": 49.0, "Business": 129.0} with open("sales.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["order_id", "date", "region", "product", "units", "unit_price"]) oid = 10000 d = date(2026, 1, 1) while d <= date(2026, 9, 30): m = d.month - 1 for region, base in regions.items(): for _ in range(random.randint(1, 3)): product = random.choices(list(products), weights=[5, 3, 1])[0] units = max(1, int(random.gauss(4, 2) * base * (1 + growth[region]) ** m)) oid += 1 w.writerow([oid, d.isoformat(), region, product, units, products[product]]) d += timedelta(days=1) ``` ```text $ python3 make_data.py && wc -l sales.csv && head -3 sales.csv 2187 sales.csv order_id,date,region,product,units,unit_price 10001,2026-01-01,North,Business,3,129.0 10002,2026-01-01,North,Team,4,49.0 ``` ## 2. The workspace and the tool Save this as `analyst.py`. ```python import json import struct import sys import time from pathlib import Path from sandboxer import Sandboxer client = Sandboxer() # reads SANDBOXER_API_KEY PY = "/home/user/.venv/bin/python" # the sandbox's analysis venv def open_workspace(name: str): """Create a sandbox and install the analysis stack once.""" sandbox = client.create(type="small", ttl_seconds=1800, name=name) t = time.time() sandbox.exec( "uv venv -q ~/.venv && uv pip install -q --python ~/.venv pandas matplotlib", timeout_seconds=300, check=True, ) print(f"installed pandas + matplotlib in {time.time() - t:.1f}s") return sandbox def png_size(data: bytes): """Width and height from a PNG header.""" return struct.unpack(">II", data[16:24]) def run_analysis(sandbox, code: str) -> dict: """Tool for the agent: run Python (pandas, matplotlib) in the sandbox. The dataset is at data/sales.csv. Save charts to out/*.png. Returns stdout, stderr, exit code and the charts it saved.""" sandbox.exec("mkdir -p out && rm -f out/*.png") sandbox.files.write("analysis.py", code) r = sandbox.exec(f"MPLBACKEND=Agg {PY} analysis.py", timeout_seconds=120) charts = [] listing = sandbox.exec("ls out/*.png 2>/dev/null").stdout.split() for path in listing: data = sandbox.files.read_bytes(path) # up to 5 MiB Path(path).parent.mkdir(exist_ok=True) Path(path).write_bytes(data) w, h = png_size(data) charts.append({"path": path, "bytes": len(data), "width": w, "height": h}) return { "exit_code": r.exit_code, "stdout": r.stdout[-4000:], # keep the model's context small "stderr": r.stderr[-2000:], "charts": charts, } # The code a model would write for "Which region grew the most, and show me a chart". ANALYSIS = r''' import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("data/sales.csv", parse_dates=["date"]) df["revenue"] = df["units"] * df["unit_price"] monthly = (df.groupby([df["date"].dt.to_period("M"), "region"])["revenue"] .sum().unstack()) q1 = monthly.loc["2026-01":"2026-03"].sum() q3 = monthly.loc["2026-07":"2026-09"].sum() summary = pd.DataFrame({ "orders": df.groupby("region").size(), "revenue": df.groupby("region")["revenue"].sum().round(0).astype(int), "q1_rev": q1.round(0).astype(int), "q3_rev": q3.round(0).astype(int), "q3_vs_q1": ((q3 / q1 - 1) * 100).round(1).astype(str) + "%", }).sort_values("q3_rev", ascending=False) print(f"{len(df)} orders, {df['date'].min():%Y-%m-%d} to {df['date'].max():%Y-%m-%d}") print(summary.to_string()) ax = monthly.plot(figsize=(8, 4.5), marker="o") ax.set_title("Monthly revenue by region, 2026") ax.set_xlabel("") ax.set_ylabel("Revenue (USD)") plt.tight_layout() plt.savefig("out/revenue_by_region.png", dpi=120) ''' if __name__ == "__main__": sandbox = open_workspace(sys.argv[1] if len(sys.argv) > 1 else "aeo-data-1") print("sandbox", sandbox.id) try: sandbox.exec("mkdir -p data") sandbox.files.write("data/sales.csv", Path("sales.csv").read_bytes()) print("uploaded", sandbox.exec("wc -c < data/sales.csv").stdout.strip(), "bytes") t = time.time() result = run_analysis(sandbox, ANALYSIS) print(f"--- call 1 ({time.time() - t:.1f}s)") print(json.dumps(result, indent=2)) # A follow-up question reuses the same sandbox: no reinstall, the CSV is still there. t = time.time() result = run_analysis(sandbox, r''' import pandas as pd df = pd.read_csv("data/sales.csv") df["revenue"] = df["units"] * df["unit_price"] print(df.pivot_table(index="region", columns="product", values="revenue", aggfunc="sum").round(0).astype(int).to_string()) ''') print(f"--- call 2 ({time.time() - t:.1f}s)") print(json.dumps(result, indent=2)) finally: sandbox.delete() print("deleted", sandbox.id) ``` A few choices in this tool matter for agents: - **One argument for the model.** The model only writes `code`. Your app owns the sandbox handle, so the model can't point the tool at someone else's sandbox. - **A venv, not `pip install --user`.** The sandbox is Ubuntu with `python3`, `pip` and `uv` pre-installed. Installing into `~/.venv` keeps the analysis stack apart from system Python, and `uv` makes the install fast. - **`MPLBACKEND=Agg`.** There's no display, so matplotlib renders straight to PNG. - **A fixed output folder.** The tool clears `out/` before each run and returns every PNG found there, so the model only has to save to `out/`. - **Trimmed output.** A `print(df)` on a big frame can flood the model's context. The tool keeps the last 4,000 characters of stdout. ## 3. Run it ```bash python3 analyst.py ``` ```text installed pandas + matplotlib in 1.1s sandbox sb_2nfdglgxhl uploaded 82402 bytes --- call 1 (5.5s) { "exit_code": 0, "stdout": "2186 orders, 2026-01-01 to 2026-09-30\n orders revenue q1_rev q3_rev q3_vs_q1\nregion \nWest 558 87224 24631 34157 38.7%\nEast 575 94246 28690 33031 15.1%\nSouth 523 73808 20425 27146 32.9%\nNorth 530 82896 27228 26698 -1.9%\n", "stderr": "", "charts": [ { "path": "out/revenue_by_region.png", "bytes": 88207, "width": 960, "height": 540 } ] } --- call 2 (1.3s) { "exit_code": 0, "stdout": "product Business Starter Team\nregion \nEast 28251 24149 41846\nNorth 30960 21850 30086\nSouth 24252 20254 29302\nWest 29670 21147 36407\n", "stderr": "", "charts": [] } deleted sb_2nfdglgxhl ``` The whole script took 11.5 seconds in this single run (October 27, 2026), including creating and deleting the sandbox. The summary table the agent gets back: | region | orders | revenue | q1_rev | q3_rev | q3_vs_q1 | | - | - | - | - | - | - | | West | 558 | 87224 | 24631 | 34157 | 38.7% | | East | 575 | 94246 | 28690 | 33031 | 15.1% | | South | 523 | 73808 | 20425 | 27146 | 32.9% | | North | 530 | 82896 | 27228 | 26698 | -1.9% | So the answer to "which region grew the most" is West, up 38.7% from Q1 to Q3. The chart is now a local file, `out/revenue_by_region.png` (88,207 bytes, 960 x 540), ready to show in your UI or attach to a chat message. The follow-up question ran in 1.3 seconds because the packages and the CSV were already in the sandbox. ## Install once, then reuse Installing per question wastes time and invites version drift. There are two ways to install only once. **Keep one sandbox per conversation.** Create it on the first question and keep the handle (or store `sandbox.id` and reopen it with `client.get(sandbox_id)`). The second call above reused the packages and the uploaded CSV. Between conversations, `sandbox.stop()` ends compute billing, and `resume()` brings back the disk and, on a machine with the same CPU type, the memory. If none is free within 2 minutes, the resume boots from disk: files are kept, running processes are not (see [snapshots](/docs/snapshots#resume)). The `ttl_seconds=1800` on create stops the sandbox even if your process crashes. **Save the stack as a named snapshot.** Install once, save the disk under a name, and create every new sandbox from it. We ran this on production: ```python # sandbox: a sandbox where the uv install above already ran t = time.time() snap = sandbox.snapshot("aeo-data-stack") print(f"snapshot {snap.name} saved in {time.time() - t:.1f}s") t = time.time() fresh = client.create(type="small", ttl_seconds=1800, name="aeo-data-3", from_="aeo-data-stack") print(f"sandbox {fresh.id} from template, ready in {time.time() - t:.1f}s") r = fresh.exec("~/.venv/bin/python -c 'import pandas, matplotlib; print(pandas.__version__, matplotlib.__version__)'") print("no install needed:", r.exit_code, r.stdout.strip()) ``` ```text snapshot aeo-data-stack saved in 2.1s sandbox sb_c687tbxo3f from template, ready in 2.6s no install needed: 0 3.1.0 3.11.2 ``` The new sandbox imported pandas without installing anything. Named snapshots have no separate charge, but what they store counts toward your plan's included storage. Remove one with `sbx snapshot rm aeo-data-stack`. See [snapshots](/docs/snapshots) and [turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template). ## File size limits | Call | Limit | Use it for | | - | - | - | | `files.write` | 5 MiB per file | The CSV or a small dataset, generated code | | `files.read` / `files.read_bytes` | 5 MiB per file | Charts, summary JSON, small result files | | `files.download` | 50 MiB per file | Larger outputs such as a Parquet file or a zip of charts | | `sbx scp` | No cap from Sandboxer | Datasets bigger than 5 MiB, in either direction | | `exec` output | 8 MiB per stream kept per command | What `print` returns; trim it before it reaches the model | Above 5 MiB, `read_bytes` fails with `413 file_too_large` and the SDK raises. We checked with a 6 MiB file: ```python sandbox.exec("mkdir -p out && head -c 6291456 /dev/urandom > out/big.parquet") try: sandbox.files.read_bytes("out/big.parquet") except SandboxerError as e: print("read_bytes:", e.status, e.code, "-", e.message) data = sandbox.files.download("out/big.parquet") print("download:", len(data), "bytes") ``` ```text read_bytes: 413 file_too_large - Files over 5 MiB: use GET /sandboxes/{sandboxId}/artifacts (50 MiB) or scp. download: 6291456 bytes ``` For a dataset over 5 MiB, upload it with `sbx scp ./sales.csv :/home/user/data/`, or have the sandbox fetch it itself with `curl` or `git clone`. Paths for the files API must be under `/home/user` or `/tmp`; relative paths start at `/home/user`. A typical chart PNG is well under the 5 MiB read limit; ours was 86 KiB. ## FAQ ### Can the model see the chart, not only the table? Yes, if your model accepts images. The tool returns the PNG bytes on your side, so you can attach them to the next model message. Through the [MCP server](/docs/mcp), the agent can read the file itself with `read_file`, which takes an `encoding` option and returns up to 5 MiB. ### Why run pandas in a sandbox instead of in my app? The model writes the code, and it can be wrong or unsafe: it can read files, open network connections or loop forever. In a sandbox it runs in its own microVM with its own disk, and `timeout_seconds` on each `exec` (1 to 600 seconds) stops a runaway script without hanging your app. ### What size sandbox does a data agent need? We used `small` (2 vCPU, 4 GB of RAM), which handled this dataset easily. For data frames in the gigabytes, pick a bigger size; see [machines](/docs/machines) for the sizes. ### What does one analysis session cost? Billing is per second while the sandbox runs. A `default` sandbox is $0.036 an hour, and `small` is half that. Our 11.5-second run cost a fraction of a cent. A stopped sandbox costs nothing for compute; its disk counts toward your plan's [included storage](/docs/billing#storage). ## Related - [Python SDK](/docs/sdk-python) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Give a LangGraph agent a Python sandbox](/guides/give-a-langgraph-agent-a-python-sandbox) - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) --- # Build a Stateful Code Interpreter with a Jupyter Kernel Source: https://sandboxer.com/guides/build-a-stateful-code-interpreter-with-jupyter. Updated 2026-10-27. **Short answer:** Install ipykernel and jupyter_client in a venv inside a Sandboxer sandbox, start the kernel with sbx exec --detach, and send each cell through a small client script. Variables persist between calls. In our test the same kernel and its variables survived sbx stop and sbx resume; a cold boot loses them, so check the kernel after resuming. ## What you build A code interpreter tool for an LLM is a long-lived Python process that runs one snippet at a time and keeps its namespace. A Jupyter kernel is that process, and the [Jupyter messaging protocol](https://jupyter-client.readthedocs.io/en/latest/messaging.html) gives you stdout, results and tracebacks for each cell. You run the kernel inside the sandbox, so the model's code never runs on your machine. | Piece | Where it runs | What it does | | - | - | - | | `ipykernel` | Sandbox, detached | Holds the variables, imports and open files | | `~/k.json` | Sandbox | Connection file: ports and the signing key. Created mode `0600` | | `kexec.py` | Sandbox, one run per cell | Sends one cell, prints its output and status | | `sbx exec` | Your app | Runs `kexec.py` with the model's code | Jupyter isn't preinstalled. The base image has Python 3.12 and `uv`. ## 1. Install the kernel ```bash sbx new --name interpreter --type small id=sb_ugjwy66ac2 # your sandbox id from `sbx new` sbx exec $id "python3 -m venv ~/jk && ~/jk/bin/pip install -q ipykernel jupyter_client && \ ~/jk/bin/python -c 'import ipykernel, jupyter_client; print(ipykernel.__version__, jupyter_client.__version__)'" ``` ```text 7.4.0 8.10.0 ``` That took 13 seconds on a `small` sandbox (single run, October 27, 2026). The venv keeps the kernel's packages apart from the system Python. ## 2. The client script `kexec.py` connects to the running kernel, executes one cell and prints what a notebook would show. Save it on your machine: ```python import os, sys from jupyter_client import BlockingKernelClient kc = BlockingKernelClient(connection_file=os.path.expanduser("~/k.json")) kc.load_connection_file() kc.start_channels() kc.wait_for_ready(timeout=10) def show(msg): c = msg["content"] t = msg["msg_type"] if t == "stream": sys.stdout.write(c["text"]) elif t in ("execute_result", "display_data"): print(c["data"].get("text/plain", "")) elif t == "error": print(f"{c['ename']}: {c['evalue']}") reply = kc.execute_interactive(sys.argv[1], output_hook=show, timeout=60) print(f"[{reply['content']['status']} #{reply['content']['execution_count']}]") ``` The last line prints `ok` or `error` and the kernel's execution counter, which tells your app whether the cell failed and whether it is still talking to the same kernel. ## 3. Start the kernel and run cells ```bash sbx scp ./kexec.py $id:/home/user/ sbx exec $id --detach "~/jk/bin/python -m ipykernel_launcher -f ~/k.json" # "pid":578 run() { sbx exec $id "~/jk/bin/python kexec.py \"$1\""; } run "x = 41" run "print(x + 1)" run "import pandas as pd" run "import os, time; pid = os.getpid(); started = time.time(); data = [i * i for i in range(10)]; pid" run "sum(data)" run "1/0" run "x" ``` ```text [ok #1] 42 [ok #2] ModuleNotFoundError: No module named 'pandas' [error #3] 578 [ok #4] 285 [ok #5] ZeroDivisionError: division by zero [error #6] 41 [ok #7] ``` Errors don't reset anything: after the `ZeroDivisionError`, `x` is still 41. To add a package mid-session, install it into the venv and import it in the next cell. The running kernel picks it up without a restart: ```bash sbx exec $id "~/jk/bin/pip install -q humanize && ~/jk/bin/python kexec.py \"import humanize; humanize.naturalsize(10**9)\"" ``` ```text '1.0 GB' [ok #8] ``` ### Per-call latency In our single test run (October 27, 2026), each `run` call took 0.63 to 0.99 seconds from our machine. An empty `sbx exec $id "true"` took 0.25 seconds, and `kexec.py` alone took 0.64 seconds inside the sandbox, mostly Python start-up and connecting to the kernel. If that matters, keep one client process connected in the sandbox and feed it cells, instead of starting `kexec.py` per cell. ## 4. Stop, resume and check the kernel ```bash sbx stop $id --wait # returns once the sandbox is archived sbx resume $id # 1.38 s to ready (single run, October 27, 2026) run "x" run "print(x + 1)" run "sum(data), pid, round(time.time() - started)" sbx exec $id --status 578 | jq -c '{status,running}' ``` ```text 41 [ok #10] 42 [ok #11] (285, 578, 193) [ok #12] {"status":"running","running":true} ``` The memory was restored in this run. It was the same kernel process (PID 578), its counter carried on from #9 to #10, and `x`, `data` and the imports were still there. We ran a second stop and `sbx resume $id --require-memory`, and `x, pid` returned `(41, 578)` again. **What `memoryRestored` said:** `null`, on both resumes in our run on 2026-10-27, when the field wasn't yet set on a successful restore. Since a fix that shipped later that night, every resume reports `memoryRestored: true` or `false`, with a `resumeReason` (`cpu_mismatch`, `resized`, `no_memory_snapshot` or `restore_failed`) when it's `false`. Checking the kernel's process and execution counter, as above, still works either way. Memory is restored only on a machine with the same CPU type: if none is free within 2 minutes, the resume boots from disk on a different one (see [snapshots](/docs/snapshots#resume)). The files are kept, but processes are lost, so your kernel and its variables are gone. Pass `--require-memory` if you'd rather wait (up to 30 minutes) than boot from disk. ## When the kernel doesn't survive We forced a cold boot by resuming at a new size (`sbx resume $id --type default`). In our run the `memoryRestored` field was `null` here too (it now reports `false` with `resumeReason: "resized"`), but the kernel was gone: ```text $ sbx exec $id --status 578 | jq -c '{status,running}' {"status":"lost","running":false} $ sbx exec $id "~/jk/bin/python kexec.py x" RuntimeError: Kernel died before replying to kernel_info ``` `~/k.json`, `kexec.py` and the venv were all still on disk. So don't rely on the field alone: after every resume, run a cheap cell and compare the execution counter. If it starts again at `#1`, or the call fails, the namespace is empty. Your app then has to rebuild it, for example by replaying the cells it has logged, or by having the model save important results to files. To have a kernel come back on its own after a cold boot, run it as a systemd service. Enabled services start on boot; hand-started processes don't: ```bash sudo tee /etc/systemd/system/kernel.service >/dev/null <<'UNIT' [Unit] Description=Jupyter kernel for the code interpreter After=network-online.target [Service] User=user WorkingDirectory=/home/user ExecStart=/home/user/jk/bin/python -m ipykernel_launcher -f /home/user/k.json Restart=always [Install] WantedBy=multi-user.target UNIT sudo systemctl daemon-reload && sudo systemctl enable --now kernel.service ``` After a `sudo reboot` inside the sandbox, `systemctl is-active kernel.service` printed `active` and `kexec.py` connected with the same `~/k.json`. As expected, the namespace was new: `y` returned `NameError` at `[error #1]`. A service brings back the kernel, not its variables. Only a memory-restoring resume keeps those. ## FAQ ### Do I need Jupyter Server or Kernel Gateway? No. `ipykernel` and `jupyter_client` are enough when your app reaches the kernel through `sbx exec` or the API. A gateway is only useful if you want to reach the kernel over HTTP. If you do, host its port as private, not public, since anyone who reaches a kernel can run code. ### Is the kernel's unencrypted TCP warning a problem? The kernel logs that it runs over TCP without encryption. ipykernel listens on `127.0.0.1` by default and the client runs in the sandbox too, so the traffic stays inside the VM. Messages are signed with the key in `~/k.json`, which is readable only by `user`. ### What does a paused interpreter cost? Nothing for compute. Billing is per second while a sandbox runs; a stopped one's disk and memory image count toward your plan's [included storage](/docs/billing#storage). An idle sandbox also pauses itself with memory after your plan's idle time, and the next `sbx exec` wakes it (see [long-running tasks](/docs/long-running#idle-auto-pause-and-wake-on-use)). ### Can I give each user their own interpreter? Yes. Create one sandbox per user or per conversation, each with its own kernel. Each sandbox is a separate microVM, so one user's code can't see another's variables or files. ## Related - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) - [Snapshots, stop and resume](/docs/snapshots) - [Long-running tasks](/docs/long-running) --- # Run a Computer-Use Agent on a Linux Desktop in the Cloud Source: https://sandboxer.com/guides/run-a-computer-use-agent-on-a-linux-desktop. Updated 2026-10-27. **Short answer:** Start a Sandboxer sandbox and open its desktop once with sbx desktop, which starts XFCE with Chrome on display :1. Give your agent two kinds of tools that run inside it, a screenshot (ffmpeg x11grab) and actions (xdotool click, type, key), and loop. The desktop URL lets you watch the screen live. ## At a glance | Topic | What we found on a fresh `default` sandbox | | - | - | | Desktop | XFCE on Xvfb, display `:1`, 1920×1080, Chrome open and maximized. It starts the first time you open it with `sbx desktop` | | Already installed | `xdotool`, `ffmpeg`, `wmctrl`, `xclip`, Python 3 with Pillow 10.2 | | Not installed | `scrot`, ImageMagick `import`, `xwd` (use `ffmpeg -f x11grab` instead) | | `DISPLAY` in `sbx exec` | Not set: export `DISPLAY=:1` yourself | | Watch it | `sbx desktop ` returns a noVNC URL that works for ten minutes | | Cost | `default` is 4 vCPU, 8 GB RAM, $0.036/hour, billed per second | A computer-use agent is a loop: take a screenshot, send it to the model, run the action the model picks, repeat. The model only sees pixels and only acts through mouse and keyboard. All you have to supply is a screen and those two kinds of tool. Everything below ran on production. We called the tool functions directly and did not run a model, because this page is about the sandbox side. Plug them into whichever model's computer-use API you use. ## 1. Start a sandbox and find the screen ```bash sbx new --type default id=sb_4943jbsept # the id from sbx new sbx desktop $id --no-open # starts the desktop and prints a viewer URL sbx exec $id "echo DISPLAY=\$DISPLAY; pgrep -a Xvfb" ``` ```text DISPLAY= 569 /usr/bin/Xvfb :1 -screen 0 1920x1080x24 -noreset -nolisten tcp +extension RANDR ``` The desktop starts the first time you open it, so run `sbx desktop` before any screenshot or `xdotool` call. Before that there is no X server, and a headed program fails with `Missing X server or $DISPLAY` (see [Playwright with real Chrome](/guides/run-browser-use-or-playwright-with-real-chrome)). Commands from `sbx exec` don't inherit `DISPLAY`, so point tools at `:1`: ```bash sbx exec $id "DISPLAY=:1 xdotool getdisplaygeometry; DISPLAY=:1 xdotool getmouselocation" ``` ```text 1920 1080 x:960 y:540 screen:0 window:20971523 ``` With `DISPLAY=:0`, the same `xdotool` call fails with `Can't open display`. ## 2. Write the tool functions Put these in `computer.py`. They run inside the sandbox, so the screenshot never crosses the network until your agent asks for it. ```python # computer.py: the tool functions a computer-use agent calls, run inside the sandbox. import os, subprocess, time from PIL import Image, ImageChops os.environ["DISPLAY"] = ":1" # the sandbox desktop def screenshot(path): subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "x11grab", "-video_size", "1920x1080", "-i", ":1", "-frames:v", "1", path], check=True) return path def xdo(*args): return subprocess.run(["xdotool", *map(str, args)], check=True, capture_output=True, text=True).stdout.strip() def click(x, y): xdo("mousemove", x, y, "click", 1) def type_text(text): xdo("type", "--delay", 20, text) def key(combo): xdo("key", combo) def active_window(): try: return xdo("getactivewindow", "getwindowname") except subprocess.CalledProcessError: return None # bare desktop: nothing focused def changed_box(a, b): return ImageChops.difference(Image.open(a).convert("RGB"), Image.open(b).convert("RGB")).getbbox() ``` `changed_box` is optional. It returns the rectangle that differs between two screenshots, which is a cheap way to check that an action did something before you spend a model call on the next screenshot. ## 3. Run one look, act, look cycle Add this to the bottom of `computer.py`. It stands in for the model: it opens a terminal, clicks into it, types a command, presses Enter and takes a second screenshot. ```python if __name__ == "__main__": # 1. Look before = screenshot("/home/user/step0.png") print("step0", Image.open(before).size, "active:", active_window()) # 2. Act: open a terminal, wait for its window subprocess.Popen(["xfce4-terminal", "--title", "agent-term"]) for _ in range(50): try: wid = xdo("search", "--sync", "--onlyvisible", "--name", "agent-term") break except subprocess.CalledProcessError: time.sleep(0.2) geo = xdo("getwindowgeometry", wid.splitlines()[0]) print(geo.replace("\n", " | ")) # 3. Act: click inside the window, type a command, press Enter pos = geo.split("Position: ")[1].split(" ")[0] x, y = map(int, pos.split(",")) click(x + 100, y + 100) type_text("echo hello from the agent > ~/agent.txt; date") key("Return") time.sleep(1) # 4. Look again and compare after = screenshot("/home/user/step1.png") print("step1", Image.open(after).size, "active:", active_window()) print("changed region:", changed_box(before, after)) print("file written:", open("/home/user/agent.txt").read().strip()) ``` ```bash sbx scp ./computer.py $id:/home/user/computer.py sbx exec $id "python3 ~/computer.py" ``` ```text step0 (1920, 1080) active: None Window 52428803 | Position: 10,85 (screen: 0) | Geometry: 817x483 step1 (1920, 1080) active: agent-term changed region: (0, 0, 1606, 564) file written: hello from the agent ``` The first screenshot showed Chrome on its New Tab page. The second showed the terminal on top of it, with the command, its output and a fresh prompt. The file on disk confirms the keystrokes reached the right window. Each PNG was 1920×1080 and 55–77 KB. ## 4. Drive the browser the same way Chrome is the window most agents need. This step focuses it, uses the address bar shortcut and loads a page: ```python # browse.py import time from computer import xdo, key, type_text, screenshot, active_window, changed_box wid = xdo("search", "--onlyvisible", "--class", "google-chrome").splitlines()[-1] xdo("windowactivate", "--sync", wid) key("ctrl+l") type_text("example.com") key("Return") time.sleep(3) print("active:", active_window()) print("changed region:", changed_box("/home/user/step1.png", screenshot("/home/user/step2.png"))) ``` ```bash sbx scp ./browse.py $id:/home/user/browse.py sbx exec $id "cd ~ && python3 browse.py" ``` ```text active: Example Domain - Google Chrome changed region: (0, 0, 1920, 1080) ``` The window title is a useful second signal next to the pixels: it tells your loop the page loaded without a model call. ## 5. Get the screenshots out When the model runs outside the sandbox, fetch each screenshot and send it with the next request: ```bash sbx scp $id:/home/user/step1.png . ``` For a faster loop, run the whole agent inside the sandbox instead. Then only model API calls leave the VM. ## 6. Watch it live ```bash sbx desktop $id --no-open ``` The response has `"mode": "vnc"` and a `desktopUrl` that opens noVNC in a browser. We checked it from outside: the page loaded (HTTP 200, title `noVNC`) and its WebSocket answered with the VNC handshake. The first request, made a second after the sandbox became ready, got a "Not found" page; a few seconds later the same call worked. That matches the docs: the first open takes a few seconds while the desktop starts. In a script, retry the desktop URL for a few seconds before your first screenshot. The URL carries a secret token and works for ten minutes. Anyone with it can see and control the screen, so keep it out of logs and shared chats. You can watch the agent and take over with your own mouse at the same time. ## Built-in agents already have these tools If you don't want to write the loop, Sandboxer's [integrated agents](/docs/agents#computer-use) get `computer` tools (screenshot, mouse, keyboard) that act on this same desktop. They use your own model key. We didn't run them for this page. ## FAQ ### Which display number does the desktop use? `:1`. Commands run with `sbx exec` don't have `DISPLAY` set, so export `DISPLAY=:1` or pass `-i :1` to ffmpeg. `:0` does not exist. ### Can I change the screen size? Yes. The screen starts at 1920×1080, and you can change it inside the sandbox with `xrandr`. A smaller screen means smaller screenshots and fewer image tokens per step. ### Does the desktop survive a stop? Usually. Stopping a sandbox saves its memory, so open windows and a running agent are still there after `sbx resume` when it lands on a machine with the same CPU type. If none is free within 2 minutes, it boots from disk: files are kept, but windows and processes are not, so open the desktop again and restart your agent (see [snapshots](/docs/snapshots#resume)). ### Do I need an LLM key to try this? No. The screenshot and action tools are plain Linux programs. You only need a model key when you connect a model to choose the actions. ## Related - [Desktop](/docs/desktop) - [Integrated agents](/docs/agents) - [Best sandboxes for browser and computer-use agents](/guides/best-sandboxes-for-browser-and-computer-use-agents) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) --- # Test Text-to-SQL Queries Against a Real Postgres Source: https://sandboxer.com/guides/test-text-to-sql-queries-against-a-real-postgres. Updated 2026-10-27. **Short answer:** Run Postgres in a Sandboxer microVM with Docker, load your schema and sample rows, and save it as a named snapshot. Give the agent a run_sql tool that runs each candidate query as a read-only role, inside a read-only transaction with a statement timeout, and returns the rows or the exact Postgres error. ## At a glance | Decision | What this guide does | Why | | - | - | - | | Database | `postgres:16` in Docker, inside a `default` sandbox | Docker is preinstalled, so there is nothing to set up on the VM | | Data | A small seed file: 4 tables, 27 rows | The agent sees real column names, types and edge cases (a refunded order) | | Who the query runs as | `agent_ro`: `SELECT` only, read-only by default, 5-second statement timeout | A write fails even if the model switches read-only off | | Tool output | JSON: columns and up to 50 rows, or the Postgres error text | The model can fix a wrong column from the error message | | Fresh state | A named snapshot of the seeded database; one sandbox per session | Every session starts from the same rows | ## What we ran, and what we didn't We have no LLM key on the test account, so there is no model in this guide. We called the `run_sql` tool directly with the queries a model might write: one correct, one with a wrong column, two writes and one runaway query. Everything else is real: the sandboxes, Postgres, the snapshot and the outputs below all ran on production (`sandboxer-sdk` 0.1.1, Python 3.12, PostgreSQL 16.15). To use a model, register `run_sql` as a tool in your agent framework. The tool doesn't change. ## 1. Start Postgres in a sandbox ```bash sbx new --name sql-seed --no-env id=sb_v9na1yk0k4 # the id that sbx new printed sbx exec $id --timeout 300 "docker run -d --name pg --restart unless-stopped \ -e POSTGRES_PASSWORD=pw -p 127.0.0.1:5432:5432 postgres:16" sbx exec $id "docker exec pg pg_isready -U postgres" ``` ```text Status: Downloaded newer image for postgres:16 /var/run/postgresql:5432 - accepting connections ``` The image pull and start took about 8 seconds (single run, October 27, 2026). `--restart unless-stopped` matters later: it brings the container back when a sandbox boots from the snapshot. The port is bound to `127.0.0.1` only, so nothing outside the VM can reach it. ## 2. Load a schema, sample data and a read-only role Save this as `seed.sql`. Use your real schema and a few hundred representative rows. ```sql CREATE DATABASE shop; \c shop CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, country text NOT NULL, created_at date NOT NULL); CREATE TABLE products (id int PRIMARY KEY, name text NOT NULL, category text NOT NULL, price_cents int NOT NULL); CREATE TABLE orders ( id int PRIMARY KEY, customer_id int NOT NULL REFERENCES customers(id), ordered_at date NOT NULL, status text NOT NULL CHECK (status IN ('paid', 'refunded')) ); CREATE TABLE order_items ( order_id int NOT NULL REFERENCES orders(id), product_id int NOT NULL REFERENCES products(id), quantity int NOT NULL, PRIMARY KEY (order_id, product_id) ); INSERT INTO customers VALUES (1, 'Ada', 'US', '2026-01-04'), (2, 'Bruno', 'DE', '2026-02-11'), (3, 'Chen', 'US', '2026-03-02'), (4, 'Dalia', 'FR', '2026-05-19'), (5, 'Emeka', 'DE', '2026-07-23'); INSERT INTO products VALUES (1, 'Keyboard', 'hardware', 8900), (2, 'Mouse', 'hardware', 2900), (3, 'Monitor', 'hardware', 24900), (4, 'IDE seat', 'software', 12000), (5, 'VPN year', 'software', 6000); INSERT INTO orders VALUES (101, 1, '2026-08-02', 'paid'), (102, 2, '2026-08-15', 'paid'), (103, 1, '2026-09-01', 'refunded'), (104, 3, '2026-09-09', 'paid'), (105, 4, '2026-09-21', 'paid'), (106, 5, '2026-10-03', 'paid'), (107, 2, '2026-10-12', 'paid'); INSERT INTO order_items VALUES (101, 1, 1), (101, 2, 2), (102, 3, 2), (103, 4, 1), (104, 4, 3), (104, 5, 1), (105, 2, 1), (106, 3, 1), (106, 1, 1), (107, 5, 4); -- The role the agent's queries run as: it can read, nothing else. CREATE ROLE agent_ro LOGIN PASSWORD 'ro'; REVOKE ALL ON SCHEMA public FROM PUBLIC; GRANT CONNECT ON DATABASE shop TO agent_ro; GRANT USAGE ON SCHEMA public TO agent_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_ro; ALTER ROLE agent_ro SET default_transaction_read_only = on; ALTER ROLE agent_ro SET statement_timeout = '5s'; ``` Copy it in and load it: ```bash sbx scp ./seed.sql $id:/home/user/ sbx exec $id "docker exec -i pg psql -U postgres -v ON_ERROR_STOP=1 < seed.sql" ``` ```text CREATE DATABASE You are now connected to database "shop" as user "postgres". CREATE TABLE … INSERT 0 10 CREATE ROLE … ALTER ROLE ``` ## 3. The run_sql tool Save this as `sqltool.py`. It writes the query to a file in the sandbox, wraps it in a read-only transaction that is always rolled back, and runs it with `psql` as `agent_ro`. ```python import json PSQL = "docker exec -i pg psql -X -q --csv -v ON_ERROR_STOP=1 -U agent_ro -d shop" MAX_ROWS = 50 def make_run_sql(sandbox): """Return a run_sql tool bound to one sandbox that has the seeded Postgres.""" def run_sql(query: str) -> str: """Run one SQL query against the shop database, read-only. Returns {"ok": true, "columns": [...], "rows": [CSV lines]} or {"ok": false, "error": "..."}.""" sandbox.files.write("query.sql", f"BEGIN TRANSACTION READ ONLY;\n{query}\n;\nROLLBACK;\n") r = sandbox.exec(f"{PSQL} < query.sql", timeout_seconds=30) if r.timed_out: return json.dumps({"ok": False, "error": "query took longer than 30 seconds"}) if r.exit_code != 0: return json.dumps({"ok": False, "error": r.stderr.strip()}) lines = r.stdout.strip().splitlines() result = {"ok": True, "columns": lines[0].split(",") if lines else [], "rows": lines[1 : MAX_ROWS + 1]} if len(lines) - 1 > MAX_ROWS: result["truncated"] = True return json.dumps(result) return run_sql ``` The query goes in a file, not on the command line, so quotes and semicolons in the model's SQL need no escaping. Inside the Postgres container, local connections don't ask for a password, so `-U agent_ro` is enough. Return the error text as it is: Postgres errors name the bad column and point at its position, which is what the model needs to fix the query. Give the model the schema in its first prompt too. The same tool can fetch it: ```python run_sql("""SELECT table_name, string_agg(column_name || ' ' || data_type, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema = 'public' GROUP BY table_name ORDER BY table_name""") ``` ```json {"ok": true, "columns": ["table_name", "columns"], "rows": ["customers,\"id integer, name text, country text, created_at date\"", "order_items,\"order_id integer, product_id integer, quantity integer\"", "orders,\"id integer, customer_id integer, ordered_at date, status text\"", "products,\"id integer, name text, category text, price_cents integer\""]} ``` ## 4. Save the seeded database as a named snapshot ```bash sbx exec $id "docker exec pg psql -U postgres -c CHECKPOINT" sbx snapshot $id sql-seeded ``` The `CHECKPOINT` flushes Postgres's data files to disk before the snapshot is taken. The save took about 2 seconds in our run. A named snapshot is independent of its source, so you can delete the seed sandbox now and still start from `sql-seeded`. To check that copies start clean, we deleted every order in the seed sandbox (as `postgres`) after saving, then started a new sandbox from the snapshot. The new one had all 7 orders. ## 5. One clean database per session Save this as `session.py`. It starts a sandbox from the snapshot, waits for Postgres, runs the candidate queries, and deletes the sandbox at the end. ```python import time from sandboxer import Sandboxer from sqltool import make_run_sql client = Sandboxer() # reads SANDBOXER_API_KEY t0 = time.monotonic() sandbox = client.create(from_="sql-seeded", no_env=True, ttl_seconds=900) sandbox.exec("until docker exec pg pg_isready -q; do sleep 0.5; done", timeout_seconds=60, check=True) print(f"{sandbox.id} ready with Postgres in {time.monotonic() - t0:.1f}s") try: run_sql = make_run_sql(sandbox) candidates = { "correct": """ SELECT c.country, round(sum(p.price_cents * oi.quantity) / 100.0, 2) AS revenue FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.status = 'paid' GROUP BY c.country ORDER BY revenue DESC""", "wrong column": "SELECT c.country, sum(o.total) FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY 1", "write": "DELETE FROM orders WHERE status = 'refunded'", "write, read-only switched off": "SET transaction_read_only = off; DELETE FROM orders WHERE status = 'refunded'", "runaway": "SELECT count(*) FROM generate_series(1, 1e12)", } for label, sql in candidates.items(): print(f"{label}: {run_sql(sql)}") finally: sandbox.delete() print(f"deleted {sandbox.id}") ``` ```text sb_6cwh96wicm ready with Postgres in 2.6s correct: {"ok": true, "columns": ["country", "revenue"], "rows": ["DE,1076.00", "US,567.00", "FR,29.00"]} wrong column: {"ok": false, "error": "ERROR: column o.total does not exist\nLINE 1: SELECT c.country, sum(o.total) FROM orders o JOIN customers ...\n ^"} write: {"ok": false, "error": "ERROR: cannot execute DELETE in a read-only transaction"} write, read-only switched off: {"ok": false, "error": "ERROR: permission denied for table orders"} runaway: {"ok": false, "error": "ERROR: canceling statement due to statement timeout"} deleted sb_6cwh96wicm ``` In this single run (October 27, 2026), from `create` to Postgres accepting connections took 2.6 seconds. Each successful query took about 0.3 seconds; the runaway one stopped at the 5-second timeout. The correct answer matches the data: Germany has 1,076.00 in paid orders, and the refunded order 103 is left out. A sandbox started from a named snapshot boots fresh from the saved disk. It doesn't restore memory, so Postgres isn't "still running": Docker starts the container again because of its restart policy, and the `pg_isready` loop waits for it. If you need to keep a session's database between turns instead, stop the sandbox and resume it later. A resume restores memory and the Postgres process with it when it lands on a machine with the same CPU type; if none is free within 2 minutes it boots from disk, so the data files are kept and the restart policy brings Postgres back (see [snapshots](/docs/snapshots#resume)). ## Why two layers of read-only | Attempt | What stops it | | - | - | | `DELETE`, `INSERT`, `DROP` | The transaction is read-only (`BEGIN TRANSACTION READ ONLY`, plus the role default) | | `SET transaction_read_only = off; DELETE …` | `agent_ro` has only `SELECT` grants: `permission denied` | | A query that never ends | `statement_timeout = '5s'` on the role | | `SET statement_timeout = 0` first | The `exec` call's own `timeout_seconds=30` | | Anything that escapes all of that | It only touches a copy in a throwaway VM; the next session starts from the snapshot | A read-only transaction alone isn't enough, because any role can turn it off. The grants are what make writes impossible. The sandbox is what makes a mistake cheap. ## FAQ ### Can I use apt instead of Docker? Yes. Each sandbox is a full Ubuntu 24.04 VM with passwordless `sudo`, so you can install Postgres with `apt` instead. We tested only the Docker route: the `postgres:16` image pins the version and starts in one command. ### Should the agent query my production database instead? Test against a copy first. Load your real schema and a sample of rows into the sandbox, let the agent iterate there, and only run the final query against production, with your own read-only credentials. The sandbox never needs a production password. ### Why one sandbox per session and not one shared database? A shared database collects whatever earlier sessions did, such as temporary tables or a superuser's test writes. Starting each session from the named snapshot gives every question the same data. Delete the sandbox when the session ends; billing is per second while it runs. ### What API key should the agent's backend use? One that can create and delete sandboxes but not change your account, such as `sbx api-key create sql-agent --preset ci`. The model itself never sees a key: it only sees the `run_sql` tool. ### How do I update the seed data? Start a sandbox from the snapshot, change the data as `postgres`, run `CHECKPOINT`, and save it again under the same name. Reusing a name replaces the snapshot. ## Related - [Snapshots](/docs/snapshots) - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) - [Docker and KVM inside an agent sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox) - [Run OpenAI Agents SDK tools in a cloud VM](/guides/run-openai-agents-sdk-tools-in-a-cloud-vm) --- # How Do I Build RL and Eval Environments With Snapshots and Forks? Source: https://sandboxer.com/guides/build-rl-and-eval-environments-with-snapshots-and-forks. Updated 2026-10-27. **Short answer:** Set up the task once in a sandbox and save it as a named snapshot. Start each episode in a fresh sandbox with sbx new --from, or sbx fork the running setup sandbox. The episode runs your policy and grader, writes a reward file you read back, and the sandbox is deleted. A fork copies disk only, never memory. ## The pattern | Step | Command | Done | | - | - | - | | Build the task environment | `sbx new`, then `sbx exec` to install it | Once | | Freeze it | `sbx snapshot env-v1` | Once per environment version | | Start an episode | `sbx new --from env-v1`, or `sbx fork ` | Once per episode | | Act and grade | `sbx exec "POLICY=… ~/env/episode.sh"` | Once per episode | | Collect the reward | `sbx exec "cat ~/result.json"` | Once per episode | | Clean up | `sbx delete --yes`, and at the end `sbx snapshot rm env-v1` | Every episode, then once | Every episode starts from the same bytes on disk, and nothing one episode writes can reach the next. That is the property RL rollouts and evals need: a reward depends on the policy, not on what the last run left behind. ## 1. Build the task once The example task is a small Python bug. `stats.py` has a broken `median`, `test_stats.py` checks it, and `grade.py` turns the pytest result into a reward between 0 and 1. ```python # env/task/stats.py (the bug the policy must fix) def median(xs): xs = list(xs) return xs[len(xs) // 2] ``` ```python # env/task/test_stats.py from stats import median def test_odd(): assert median([3, 1, 2]) == 2 def test_even(): assert median([4, 1, 3, 2]) == 2.5 def test_single(): assert median([7]) == 7 def test_sorted(): assert median([1, 2, 3, 4, 5]) == 3 ``` ```python # env/grade.py: run the tests, write the reward import json, subprocess, sys, time t = time.time() r = subprocess.run([sys.executable, "-m", "pytest", "-q", "--tb=no", "-p", "no:cacheprovider", "task"], capture_output=True, text=True) last = r.stdout.strip().splitlines()[-1] # e.g. "1 failed, 3 passed in 0.02s" passed = int(last.split(" passed")[0].split()[-1]) if " passed" in last else 0 failed = int(last.split(" failed")[0].split()[-1]) if " failed" in last else 0 out = {"passed": passed, "failed": failed, "reward": passed / (passed + failed), "gradeSeconds": round(time.time() - t, 2)} json.dump(out, open("/home/user/result.json", "w")) print(json.dumps(out)) ``` ```bash #!/bin/bash # env/episode.sh: one episode. The policy edits the task, the grader scores it. set -e cd ~/env cp "policies/$POLICY.py" task/stats.py # stand-in for your agent's action .venv/bin/python grade.py ``` The "policy" here is a file copy: `policies/0.py`, `1.py` and `2.py` are three candidate fixes. In a real run, this line is where your agent or model acts. We didn't call a model for this page, so the policies are fixed files, and everything else ran as shown. Create the setup sandbox, copy the files in, and install the grader's dependencies: ```bash sbx new --name aeo-rl-1 --type small --ttl 3600 src=sb_njm7z1r1ie # the id sbx new printed tar czf env.tgz env sbx exec $src "echo $(base64 -w0 env.tgz) | base64 -d | tar xz && chmod +x env/episode.sh" sbx exec $src --timeout 300 "cd env && python3 -m venv .venv && .venv/bin/pip install -q pytest==8.3.3 && .venv/bin/python -m pytest --version" sbx exec $src "cd env && .venv/bin/python grade.py" # baseline: the unfixed bug ``` ```text pytest 8.3.3 {"passed": 2, "failed": 2, "reward": 0.5, "gradeSeconds": 0.42} ``` The unfixed task scores 0.5, which tells you the grader works before any policy touches it. ## 2. Save it as a named snapshot Remove anything an episode would write, then save: ```bash sbx exec $src "rm -f ~/result.json; sync" sbx snapshot $src aeo-rl-env-v1 ``` ```json {"ok":true,"type":"snapshot.named.info","snapshot":{"name":"aeo-rl-env-v1","sizeBytes":20147339264,"snapshotId":"506de5ae-…","sourceSandboxId":"sb_njm7z1r1ie","status":"ready","type":"small",…}} ``` A named snapshot is independent of its source, so you can delete the setup sandbox and keep starting episodes from the snapshot. Version the name (`env-v1`, `env-v2`) so a result always points at the environment that produced it. Reusing a name replaces the snapshot. ## 3. Run the episodes This loop runs three episodes, one sandbox each and one at a time. Episodes 1 and 2 start from the named snapshot. Episode 3 is a fork of the setup sandbox while it is still running. Each sandbox gets a 10-minute TTL, so a crashed loop can't leave it running for long. ```bash #!/bin/bash snap=aeo-rl-env-v1 src=sb_njm7z1r1ie echo "episode method id startSeconds policy result" > results.txt for ep in 1 2 3; do policy=$((ep - 1)) t0=$(date +%s.%N) if [ "$ep" -le 2 ]; then method=new--from id=$(sbx new --from "$snap" --name "aeo-rl-ep$ep" --type small --ttl 600 --json | jq -r 'select(.event=="ready") | .id') else method=fork sbx fork "$src" --type small --ttl 600 --wait --json > fork.json id=$(jq -r '.id // .sandbox.id' fork.json | head -1) fi start=$(echo "$(date +%s.%N) - $t0" | bc) [ "$method" = fork ] && sbx rename "$id" --name "aeo-rl-ep$ep" > /dev/null sbx exec "$id" "test ! -e ~/result.json && echo clean-start" sbx exec "$id" "POLICY=$policy ~/env/episode.sh" > /dev/null result=$(sbx exec "$id" "cat ~/result.json") echo "$ep $method $id $start $policy $result" >> results.txt sbx info "$id" | jq -c '{name: .sandbox.name // .name, memoryRestored: (.sandbox.memoryRestored // .memoryRestored), resumedFrom: (.sandbox.resumedFrom // .resumedFrom)}' sbx delete "$id" --yes > /dev/null && echo "deleted $id" done cat results.txt ``` The output, from prod: ```text clean-start {"name":"aeo-rl-ep1","memoryRestored":null,"resumedFrom":null} deleted sb_s5j8419fne clean-start {"name":"aeo-rl-ep2","memoryRestored":null,"resumedFrom":null} deleted sb_q1ietwn70b clean-start {"name":"aeo-rl-ep3","memoryRestored":null,"resumedFrom":null} deleted sb_jr8xih7onk episode method id startSeconds policy result 1 new--from sb_s5j8419fne 3.192210893 0 {"passed": 3, "failed": 1, "reward": 0.75, "gradeSeconds": 0.21} 2 new--from sb_q1ietwn70b 3.194805196 1 {"passed": 4, "failed": 0, "reward": 1.0, "gradeSeconds": 0.62} 3 fork sb_jr8xih7onk 3.391430232 2 {"passed": 4, "failed": 0, "reward": 1.0, "gradeSeconds": 0.39} ``` With `--wait` and `--json`, `sbx fork` prints JSON Lines (`forking`, `state`, `ready`), each with the new id, which is why the loop keeps only the first one. The `memoryRestored` line is there for completeness only: today it reads `null` in many cases, including after a memory restore, so don't use it to judge an episode. ## Results Single measurements on 2026-10-27, `small` sandboxes on prod, timed from the client with `date` around the command until the sandbox was ready to run commands: | Episode | Started with | Policy | Start to ready | Tests passed | Reward | Grading | | - | - | - | - | - | - | - | | 1 | `sbx new --from aeo-rl-env-v1` | sort, take the middle | 3.19 s | 3 of 4 | 0.75 | 0.21 s | | 2 | `sbx new --from aeo-rl-env-v1` | sort, average the two middles if even | 3.19 s | 4 of 4 | 1.0 | 0.62 s | | 3 | `sbx fork` of the running setup sandbox | the mean of the list | 3.39 s | 4 of 4 | 1.0 | 0.39 s | For comparison, the setup steps that every episode skipped took 3.2 s to create the sandbox, 6 s to make the venv and install pytest, and 2.1 s to save the named snapshot. A real environment with a repo, a database or model weights takes much longer to build, and the episode start stays the same. **Read the table before you trust it.** Policy 2 returns the mean, not the median, and still scored 1.0, because every test list here has the same mean and median. The isolation worked. The grader was too weak. A test such as `median([1, 2, 10]) == 2` would have caught it. Look at what scores well before you train on it, since the reward is only as good as the tests behind it. ## Fork or new --from? | | `sbx new --from ` | `sbx fork ` | | - | - | - | | Starts from | The disk saved under the name, frozen when you saved it | The source's disk at the moment of the fork (a running source keeps running) | | Needs the source sandbox | No: it still works after you delete the source | Yes: the source must still exist | | Memory and processes | Not copied: the sandbox boots fresh | Not copied: the sandbox boots fresh | | Name | `--name` | `sbx rename --name …` after the fork (the default is ` (fork)`) | | Best for | A fixed, versioned environment that many runs share | Branching from a state you just reached, such as an agent halfway through a task | A fork never copies RAM or running processes. If an episode needs a server running, start it in `episode.sh`, or install it as an enabled systemd service before you snapshot. Enabled services start when the sandbox boots. ## Scale it past three - **Starts are rate limited.** Creates, resumes and forks each count as one start. The $20 plan allows 12 a minute, 60 an hour and 200 a day, and runs up to 100 sandboxes at once (see [billing](/docs/billing#plans)). Check what's left before a burst with `sbx limits --json | jq '.starts'`. A `429 rate_limited` reply carries `Retry-After` and `details.resetsAt`. - **Run in parallel, up to your limit.** Start several episodes at once in a shell loop with `&` or with the SDK. Episodes don't share anything, so their order doesn't matter. - **Cost is per second.** A `small` sandbox costs $0.018/hour, metered from the moment it is up until it is deleted or stopped, so short episodes cost seconds, not hours. - **Always clean up.** Delete each episode when you have its result, and give each one a short `--ttl` as a backstop. To find leftovers by name: ```bash sbx list --filter sr --json | jq -r '.sandboxes[] | select(.name | startswith("aeo-rl-")) | .id' ``` When the run is done, delete the setup sandbox and the snapshot: ```bash sbx delete sb_njm7z1r1ie --yes sbx snapshot rm aeo-rl-env-v1 ``` ```json {"ok":true,"type":"snapshot.named.deleted","name":"aeo-rl-env-v1","status":"deleted"} ``` ## FAQ ### Does each episode really start clean? Yes. Each episode is a separate VM booted from the same saved disk. In our run, every episode confirmed that `~/result.json` from earlier episodes was absent before its policy ran. ### Does a fork copy the running process state of the source? No. A fork copies the disk only. For a running source, that is its live disk, frozen for the copy. The fork boots fresh, with no RAM or processes from the source (see [snapshots](/docs/snapshots#fork)). To keep memory, stop and resume the same sandbox instead; that restores memory on a machine with the same CPU type, and boots from disk with files kept but processes lost if none is free within 2 minutes. ### How fast does an episode sandbox start? In our single measurements on 2026-10-27, `small` sandboxes were ready in 3.19 s from a named snapshot and 3.39 s as a fork, timed from the client. The disk is a copy-on-write reflink, so a large environment starts about as fast as a small one. ### Can I run the grader somewhere the policy can't touch? Yes. Keep hidden tests out of the snapshot and copy them in with `sbx exec` only after the policy has finished, then grade and read the reward. A policy that runs before the tests arrive can't read or edit them. ### What does storing the environment cost? Named snapshots have no separate charge. What they store counts toward your plan's included storage, and `sbx snapshot rm` releases it. ## Related - [Snapshots, forks and named templates](/docs/snapshots) - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) - [Can you fork a running sandbox with its memory?](/guides/fork-a-running-sandbox-with-memory) - [Limits and errors](/docs/limits) --- # How Do I Get a Live Preview URL for Every Pull Request? Source: https://sandboxer.com/guides/get-a-preview-url-for-every-pull-request. Updated 2026-10-27. **Short answer:** Have CI create one small sandbox per pull request, labelled with the PR number. It checks out the PR head, builds, serves the app and runs sbx host for an HTTPS URL you post on the PR. Idle previews pause and wake on the next visit. When the PR closes, find the sandbox by label and delete it. ## At a glance | PR event | What the workflow does | Command | | - | - | - | | Opened | Create a `small` sandbox, label it `preview=,pr=` | `sbx new --no-env --idle 15`, then `PATCH /sandboxes/{id}` with `labels` | | Opened or new push | Check out `pull//head`, install, build, serve, host the port | `sbx exec`, `systemd-run`, `sbx host $id 8080` | | Nobody looks at it | The sandbox pauses with its memory; compute isn't billed | `idleStopMinutes` (15 here) | | A reviewer opens the link | The request wakes the sandbox and is then served | nothing to run | | Closed or merged | Find the sandbox by its labels and delete it | `GET /sandboxes?labels=…`, `sbx delete --yes` | We ran both scripts below on 2026-10-27 against [sveltejs/template](https://github.com/sveltejs/template), a small public web app, using its PR #291 (it changes the asset paths in `index.html` to relative ones, which makes it easy to see that the preview serves the PR and not `master`). Tokens are replaced with ``. We did not run the GitHub Actions side; the workflow at the end calls the same two scripts. ## The "PR opened or updated" script Save this as `scripts/preview-up.sh`. It needs `sbx`, `curl`, `jq` and `SANDBOXER_API_KEY` in the environment. Labels are set and searched through the REST API ([labels](/docs/api#labels)); the small `api` helper passes the key on stdin so it never shows up in the process list. ```bash #!/usr/bin/env bash # preview-up.sh : start or refresh one pull request's preview and print its URL. set -euo pipefail repo="$1"; pr="$2"; app="${PREVIEW_APP:-web}" API=https://sandboxer.com/api/v1 api() { printf 'Authorization: Bearer %s\n' "$SANDBOXER_API_KEY" | curl -fsS -H @- -H 'Content-Type: application/json' "$@"; } # 1. Find this PR's sandbox by its labels, or create one and label it. id=$(api "$API/sandboxes?labels=preview=$app,pr=$pr" | jq -r '.sandboxes[0].id // empty') if [ -z "$id" ]; then id=$(sbx new --name "${NAME_PREFIX:-preview-pr-}$pr" --type small --no-env --idle 15 --ttl 604800 --json | jq -r 'select(.event == "ready") | .id') api -X PATCH "$API/sandboxes/$id" -d "{\"labels\":{\"preview\":\"$app\",\"pr\":\"$pr\"}}" >/dev/null else sbx resume "$id" >/dev/null 2>&1 || true # only needed after auto-stop; idle pauses wake on their own fi # 2. Check out the PR head, install and build. sbx exec "$id" --timeout 600 "sudo systemctl stop preview 2>/dev/null; rm -rf app && git clone --quiet $repo app && cd app && git fetch --quiet origin pull/$pr/head:pr && git checkout --quiet pr && npm install --silent && npm run build --silent" >&2 # 3. Serve it as a systemd unit, not a detached API command, so idle auto-pause can kick in. sbx exec "$id" "sudo systemd-run --quiet --collect --uid=user --working-directory=/home/user/app \ --unit=preview npm run start -- --host 0.0.0.0 --port 8080" >&2 # 4. Give port 8080 its HTTPS URL (private: it carries a _token). sbx host "$id" 8080 --title "PR #$pr" --json | jq -r .url ``` Why these flags: - `--no-env` gives the sandbox none of your account's secrets or credentials. PR code is untrusted; it gets a clean machine. - `--type small` (2 vCPU, 4 GB) is enough to build and serve most front ends. Use `default` for heavier builds. - `--idle 15` pauses the preview after 15 idle minutes. `--ttl 604800` is a 7-day hard stop in case the "closed" event never arrives. - `GET /sandboxes?labels=k=v,k2=v2` returns sandboxes carrying all those labels, in every state, so a second push finds the same sandbox. First run (PR opened): ```bash PREVIEW_APP=sveltejs-template ./preview-up.sh https://github.com/sveltejs/template 291 ``` ```text src/main.js → public/build/bundle.js... created public/build/bundle.js in 545ms https://coastal-eager-oak-8080.box.sandboxer.com?_token= real 0m13.891s ``` Create, clone, install, build, serve and host took 14 seconds end to end (single run, October 27, 2026). Then we checked the URL: ```bash curl -sS -o /dev/null -w "%{http_code}\n" https://coastal-eager-oak-8080.box.sandboxer.com/ # 401 curl -sSL -c jar -b jar "https://coastal-eager-oak-8080.box.sandboxer.com?_token=" | grep href ``` ```text ``` Without the token the proxy answered `401`. With it, the first response was a `302` that set a cookie, and the page carried the PR's relative `./` paths (`master` has `/global.css`). Second run with the same PR number (a new push): the script found the sandbox by label, rebuilt, and printed the **same URL and token** in 5.8 seconds. The page answered `200` afterwards. ```bash sbx list --all --json | jq -c '.sandboxes[] | select(.labels.pr=="291") | {id,name,type,idleStopMinutes,noEnv,labels}' ``` ```json {"id":"sb_i5kr6c0ha2","name":"aeo-preview-pr-291","type":"small","idleStopMinutes":15,"noEnv":true,"labels":{"pr":"291","preview":"sveltejs-template"}} ``` ## Private or public URL `sbx host` returns a private URL by default. Anyone with the full URL can open it, so posting it in a PR comment shares it with everyone who can read the PR. For a public repository, or for previews you want to link from anywhere, make it public: ```bash sbx host sb_i5kr6c0ha2 8080 --public ``` ```json {"url":"https://coastal-eager-oak-8080.box.sandboxer.com","access":"public"} ``` The same hostname then answered `200` without a token. The URL keeps its hostname across pauses and resumes, so the link in the PR stays valid for the life of the preview. ## The "PR closed" script Save this as `scripts/preview-down.sh`. GitHub sends `closed` for both merged and unmerged PRs. ```bash #!/usr/bin/env bash # preview-down.sh : delete that pull request's preview sandbox. set -euo pipefail app="${PREVIEW_APP:-web}" API=https://sandboxer.com/api/v1 api() { printf 'Authorization: Bearer %s\n' "$SANDBOXER_API_KEY" | curl -fsS -H @- -H 'Content-Type: application/json' "$@"; } api "$API/sandboxes?labels=preview=$app,pr=$1" | jq -r '.sandboxes[].id' | while read -r id; do sbx delete "$id" --yes; done ``` ```text {"ok":true,"type":"deletion.operation","operation":{…,"stage":"completed","status":"completed","targetId":"sb_i5kr6c0ha2"}} ``` Running it a second time found nothing and exited `0`, so a re-delivered event is harmless. The preview hostname answered `404` after the delete. ## Stop previews when nobody is looking A sandbox with `idleStopMinutes` set pauses itself after that many minutes without activity. A pause keeps files and, when the wake lands on a machine with the same CPU type, memory. Otherwise it boots from disk with the files kept but the server stopped, because a `systemd-run` unit is transient; the next `preview-up.sh` run starts it again. While it's paused, compute isn't billed, and the next request to a hosted URL wakes it: the request is held while it resumes (up to 45 seconds) and then served. See [idle auto-pause and wake on use](/docs/long-running#idle-auto-pause-and-wake-on-use). Two things we hit while testing with `sbx idle 1`: 1. **Don't serve with `sbx exec --detach`.** A detached command counts as a running command, and that keeps the sandbox awake. With the dev server started that way, the sandbox stayed `ready` for over 3 minutes on a 1-minute idle setting. That's why the script starts the server with `systemd-run`, which the API doesn't track as a command. 2. **Once the server ran under systemd, the pause worked.** The last activity was at 02:31:11 UTC and the sandbox was paused (`"reason":"idle"`) at 02:32:38. Then we woke it through the preview URL (this was our first test sandbox, so the hostname differs): ```bash curl -sS -o /dev/null -w "%{http_code} in %{time_total}s\n" https://curious-rare-cedar-8080.box.sandboxer.com/ curl -sSL -c jar -b jar -o /dev/null -w "%{http_code} in %{time_total}s\n" "https://curious-rare-cedar-8080.box.sandboxer.com?_token=" ``` ```text 503 in 0.194514s 200 in 12.713457s ``` A request without the token got `503` and didn't wake the paused sandbox; that's expected for a private port. The request with the token woke it and was served in 12.7 seconds, and the next request took 0.2 seconds. A public port wakes for any request. Auto-stop (the `--ttl` deadline) is different: a sandbox stopped that way doesn't wake by itself. The script resumes it on the next push, and a reviewer can run `sbx resume `. ## What a preview costs Billing is per second while the sandbox runs. A paused or stopped sandbox costs nothing for compute; its disk counts toward your plan's [included storage](/docs/billing), which is why the closed step deletes it. | Preview | Size | Running time | Cost | | - | - | - | - | | Kept running for a PR open 3 days | `small` at $0.018/hour | 72 hours | about $1.30 | | Same PR, 15-minute idle pause, opened 6 times for 10 minutes | `small` at $0.018/hour | about 2.5 hours (each visit plus the 15-minute tail) | about $0.05 | | Same, heavier app | `default` at $0.036/hour | about 2.5 hours | about $0.09 | `sbx usage ` shows what one sandbox used. Ours, 33 seconds after creation (`seconds` is already scaled by the `small` multiplier of 0.5): ```json {"billingMultiplier":0.5,"dollars":0.00016,"running":true,"sandboxType":"small","seconds":16,…} ``` ## The GitHub Actions workflow This is the workflow that would call the two scripts. We did not run it on GitHub; the scripts it calls are the ones tested above. Add an API key as the repository secret `SANDBOXER_API_KEY` (the `ci` preset covers create, resume and delete-own; see [API keys](/docs/api-keys)). ```yaml name: PR preview on: pull_request: types: [opened, synchronize, reopened, closed] permissions: contents: read pull-requests: write concurrency: group: preview-${{ github.event.pull_request.number }} env: SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }} PREVIEW_APP: ${{ github.event.repository.name }} PR: ${{ github.event.pull_request.number }} jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install sbx run: | mkdir -p "$HOME/.local/bin" curl -fsSL https://sandboxer.com/dl/sbx-linux-amd64 -o "$HOME/.local/bin/sbx" chmod +x "$HOME/.local/bin/sbx" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Start or refresh the preview if: github.event.action != 'closed' env: GH_TOKEN: ${{ github.token }} run: | url=$(scripts/preview-up.sh "${{ github.server_url }}/${{ github.repository }}" "$PR") echo "::add-mask::$url" gh pr comment "$PR" --edit-last --body "Preview: $url" || gh pr comment "$PR" --body "Preview: $url" - name: Delete the preview if: github.event.action == 'closed' run: scripts/preview-down.sh "$PR" ``` - `concurrency` stops a push and a close for the same PR from racing. - `::add-mask::` keeps the private URL out of the job log; it is still in the PR comment. - For pull requests from forks, GitHub doesn't pass repository secrets to the workflow ([GitHub docs](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)), so this workflow only previews branches in your own repository unless you change how it is triggered. ## Adapting it to your app - **Vite, Next.js and similar:** replace the build-and-serve lines with your app's commands, and bind to `0.0.0.0`. If the server only listens on `127.0.0.1`, run `sbx host $id --localhost` instead. Vite rejects unknown hosts unless `.box.sandboxer.com` is in `server.allowedHosts` (see [Hosting](/docs/hosting)). - **Faster installs:** install dependencies once in a sandbox, save it with `sbx snapshot app-main`, and create previews with `sbx new --from app-main` so each PR only installs what changed. - **Private repositories:** `--no-env` leaves out your saved git credentials, so pass a read-only token for that repository with `-e` and clone with it. ## FAQ ### Does every push create a new sandbox? No. The script looks the sandbox up by its `preview` and `pr` labels and reuses it, so a push rebuilds in place and the URL and token stay the same. In our single test run the second run took 5.8 seconds. ### What does a reviewer see if the preview is paused? The page loads slower the first time. The request is held while the sandbox resumes and is then served; ours took 12.7 seconds in a single run. If a resume takes longer than 45 seconds the proxy answers `503` with `Retry-After`. ### Why not keep the dev server running with sbx exec --detach? A detached command keeps the sandbox awake, so it never pauses for being idle. Run the server as a systemd unit, as the script does. The API doesn't count a systemd unit as a running command, so the sandbox can still pause. ### Can PR code read my Sandboxer account or secrets? Not with `--no-env`. The sandbox starts without your account's secrets or credentials and without an in-sandbox API key, and each sandbox is its own microVM. ### What happens if the "closed" event is missed? The 7-day `--ttl` stops the sandbox, which ends compute billing. It still uses storage until it is deleted, so running `preview-down.sh` for old PR numbers, or listing sandboxes with the `preview` label, is a good weekly cleanup. ## Related - [Hosting](/docs/hosting) - [Long-running tasks: idle auto-pause and wake on use](/docs/long-running) - [How do I give a port in a cloud sandbox an HTTPS URL?](/guides/give-a-sandbox-port-an-https-url) - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) --- # Run Browser Use or Playwright With Real Chrome in a Cloud VM Source: https://sandboxer.com/guides/run-browser-use-or-playwright-with-real-chrome. Updated 2026-10-27. **Short answer:** Every Sandboxer VM has Google Chrome installed. Install Playwright or Browser Use with uv, point it at that Chrome, and run headless right away. To watch, open the desktop with sbx desktop, then launch Chrome headed on display :1, and it appears in the live stream in your browser. ## At a glance | | What we found on a `default` sandbox (4 vCPU, 8 GB, $0.036/hour) | | - | - | | Chrome | `/usr/bin/google-chrome`, Google Chrome 155.0.8059.39, already installed | | Runtimes | Python 3.12.3, Node 22, `uv` and `npm` already installed | | Browser download | Not needed: Playwright's `channel="chrome"` uses the installed Chrome | | Headless | Works as soon as the sandbox is ready | | Headed (to watch) | Needs the desktop started once with `sbx desktop`; it runs on display `:1` | | Watching | `sbx desktop` returns a noVNC URL with a token that works for 10 minutes | | Stop and resume | The browser, its tabs and cookies kept running with the same process IDs (memory restores only on a machine with the same CPU type; see step 5) | ## 1. Check Chrome is there ```bash id=$(sbx new --json | jq -r 'select(.event == "ready") | .id') sbx exec $id "which google-chrome; google-chrome --version" ``` ```text /usr/bin/google-chrome Google Chrome 155.0.8059.39 ``` ## 2. Playwright, headless Install Playwright into a virtual environment. You don't need `playwright install`, because `channel="chrome"` tells Playwright to use the Chrome that's already on the machine ([Playwright docs on browser channels](https://playwright.dev/python/docs/browsers#google-chrome--microsoft-edge)). ```python # shot.py from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(channel="chrome", headless=True) page = browser.new_page() page.goto("https://example.com") print(browser.version, "|", page.title()) page.screenshot(path="example.png") browser.close() ``` ```bash sbx scp shot.py $id:/home/user/shot.py sbx exec $id "uv venv -q bu && . bu/bin/activate && uv pip install -q playwright" sbx exec $id ". bu/bin/activate && python shot.py && file example.png" ``` ```text 155.0.8059.39 | Example Domain example.png: PNG image data, 1280 x 720, 8-bit/color RGB, non-interlaced ``` The whole run took 1.6 seconds (single run, October 27, 2026). Copy the screenshot back with `sbx scp $id:/home/user/example.png .`. The same works from Node. `npm i playwright` installs the library only, and `channel: "chrome"` launches the installed Chrome: ```javascript // shot.mjs import { chromium } from "playwright"; const browser = await chromium.launch({ channel: "chrome", headless: !process.env.HEADED }); const page = await browser.newPage(); await page.goto("https://example.com"); console.log(browser.version(), "|", await page.title()); await page.screenshot({ path: "example.png" }); await browser.close(); ``` ```text 155.0.8059.39 | Example Domain ``` ## 3. Watch it: start the desktop, then run headed The desktop (XFCE, streamed over noVNC) starts the first time you open it. Before that there is no X server, and a headed launch fails. This is what we got when we tried it first: ```text $ sbx exec $id "cd pw && HEADED=1 DISPLAY=:1 node shot.mjs" browserType.launch: Target page, context or browser has been closed …ERROR:ui/ozone/platform/x11/ozone_platform_x11.cc:257] Missing X server or $DISPLAY ``` So open the desktop first. `sbx desktop $id` opens it in your browser; `--no-open` only prints the URL. With `--json` you see the whole response: ```bash sbx desktop $id --no-open --json ``` ```json {"ok":true,"type":"desktop.url","desktopUrl":"https://ocean-curious-panda-6080.box.sandboxer.com/vnc.html?_token=…&autoconnect=true&path=websockify&reconnect=true&resize=remote", …,"message":"WebRTC streaming is not available yet; streaming over VNC.","mode":"vnc", …} ``` The URL carries a secret token: anyone who has it can see and control the screen. It works for ten minutes, and a browser that opens it in that time stays signed in for 8 hours. Without the token the same page returns `401`. Now the screen exists. On the sandboxes we tested it was display `:1`; check with `pgrep -a Xvfb` if you script this: ```bash sbx exec $id "pgrep -a Xvfb" ``` ```text 760 /usr/bin/Xvfb :1 -screen 0 1920x1080x24 -noreset -nolisten tcp +extension RANDR ``` Run the script headed on that display, in the background so it keeps going after `sbx exec` returns: ```python # keep.py: a headed browser with a profile on the disk import time from playwright.sync_api import sync_playwright with sync_playwright() as p: ctx = p.chromium.launch_persistent_context( "/data/chrome-agent", channel="chrome", headless=False, no_viewport=True) page = ctx.pages[0] page.goto("https://httpbin.org/cookies/set?session=signed-in") print("cookies:", [c["name"] + "=" + c["value"] for c in ctx.cookies()], flush=True) while True: # keep the browser open; your agent works here print(time.strftime("%H:%M:%S"), page.url, flush=True) time.sleep(5) ``` ```bash sbx exec $id "sudo mkdir -p /data && sudo chown user /data" sbx scp keep.py $id:/home/user/keep.py sbx exec $id --detach ". bu/bin/activate && DISPLAY=:1 python keep.py > keep.log 2>&1" sbx exec $id "head -2 keep.log" ``` ```text cookies: ['session=signed-in'] 02:24:39 https://httpbin.org/cookies ``` The Chrome window is drawn on display `:1`, the screen the desktop stream shows; a screen grab of `:1` a few seconds later showed it open on the httpbin page. To keep a record instead of watching live, grab the screen with `ffmpeg -f x11grab -i :1` (see [Desktop](/docs/desktop#record-the-screen)); use `:1`, the display you found above. **`sbx browser` is a different view.** It streams a separate Chrome with its own profile (`--profile /data/chrome-work`), with tabs and address bar only. In our test it ran on its own screen (display `:2`, at the `-6081` address), not the one your script draws on (`:1`, at `-6080`). Use `sbx desktop` to watch a Playwright or Browser Use run, and `sbx browser` when a person needs a browser to sign in or browse. ## 4. Browser Use, without an LLM [Browser Use](https://docs.browser-use.com) is a Python library that gives a language model control of a browser. Its agent needs a model key, which we didn't use here, so we ran only its browser part: start a session on the installed Chrome, load a page, and read the page state an agent would be given. ```python # bu_session.py import asyncio, base64 from browser_use import BrowserSession async def main(): session = BrowserSession( executable_path="/usr/bin/google-chrome", # the installed Chrome headless=False, # draw it on the desktop user_data_dir="/data/chrome-agent", # profile kept on the disk keep_alive=True, ) await session.start() await session.navigate_to("https://news.ycombinator.com") state = await session.get_browser_state_summary(include_screenshot=True) print("url:", state.url) print("title:", state.title) print("clickable elements:", len(state.dom_state.selector_map)) print(state.dom_state.llm_representation()[:400]) with open("bu.png", "wb") as f: f.write(base64.b64decode(state.screenshot)) await session.kill() asyncio.run(main()) ``` ```bash sbx exec $id ". bu/bin/activate && uv pip install -q browser-use" # 0.13.14 in our run sbx scp bu_session.py $id:/home/user/bu_session.py sbx exec $id ". bu/bin/activate && DISPLAY=:1 ANONYMIZED_TELEMETRY=false python bu_session.py" ``` ```text INFO [utils] … Downloading Force Background Tab extension... … url: https://news.ycombinator.com/ title: news.ycombinator.com clickable elements: 557 [32] [37] Hacker News [39] new … ``` The numbered list is the page as Browser Use describes it to the model. On first start the library downloads a few Chrome extensions, which is the log line at the top; `ANONYMIZED_TELEMETRY=false` turns off its telemetry. To run a real agent, pass this session to it with your model: `Agent(task=..., llm=..., browser_session=session)`. The run shows up in the desktop stream the same way as the Playwright window. ## 5. Stop it and come back to the same browser Stopping a sandbox saves its memory. We stopped the sandbox above while `keep.py` was running, waited about 25 seconds, and resumed it: ```bash sbx stop $id --wait # returns once the sandbox is archived sbx resume $id sbx exec $id "tail -4 keep.log; pgrep -f 'python keep.py' | head -1; pgrep -o -f 'chrome.*chrome-agent'" ``` ```text 02:24:54 https://httpbin.org/cookies 02:25:21 https://httpbin.org/cookies 02:25:27 https://httpbin.org/cookies 02:25:32 https://httpbin.org/cookies 2497 2514 ``` The script and Chrome kept their process IDs (2497 and 2514, the same as before the stop), the log picks up after the gap, and a fresh `sbx desktop` URL loaded again. A screen grab of `:1` showed the same window with the `session=signed-in` cookie on the page. Memory is restored only on a machine with the same CPU type. If none is free within 2 minutes, the resume boots from disk: the profile on `/data` is kept, but Chrome, the script, open tabs and session-only cookies are gone, so start them again (see [snapshots](/docs/snapshots#resume)). A stopped sandbox costs nothing for compute. The profile directory `/data/chrome-agent` is on the disk, so a [named snapshot](/docs/snapshots) or a fork keeps it. Open tabs and session-only cookies live in memory, so only a stop and resume keeps those. ## FAQ ### Do I need to run `playwright install` in the sandbox? No. Launch with `channel="chrome"` (Python) or `channel: "chrome"` (Node) and Playwright uses the Google Chrome that's already installed. We never ran `playwright install` in these tests. ### Why does my headed browser fail with "Missing X server or $DISPLAY"? The desktop hasn't been started yet, or `DISPLAY` points at the wrong screen. Run `sbx desktop ` once, then use `DISPLAY=:1` (confirm with `pgrep -a Xvfb`). Headless runs don't need a display. ### Can someone else watch the run? Yes, if you give them a desktop URL from `sbx desktop`. Anyone with the URL can also control the screen, so treat it like a password. For an embedded view in your own product, put the URL in an iframe on a page that requires your own sign-in. ### Can I run a full Browser Use agent here? Yes, with your own model key in the sandbox. We tested the browser session without a model, as shown above; the agent itself calls your LLM provider and is billed by them. ## Related - [Desktop](/docs/desktop) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Best sandboxes for browser and computer-use agents](/guides/best-sandboxes-for-browser-and-computer-use-agents) - [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) --- # How Do I Run Each GitHub Actions Job in a Fresh MicroVM? Source: https://sandboxer.com/guides/run-each-github-actions-job-in-a-fresh-microvm. Updated 2026-10-28. **Short answer:** Either keep GitHub's hosted runner and have the job call sbx to create a sandbox, copy the checkout in, run the tests there and delete it, or start a sandbox per job that registers an --ephemeral self-hosted runner, takes exactly one job and is then deleted. ## At a glance | | A: hosted runner drives `sbx` | B: ephemeral runner inside a sandbox | | - | - | - | | Where the job's steps run | GitHub's runner; only the commands you pass to `sbx exec` run in the microVM | Every step, including `actions/checkout`, runs in the microVM | | GitHub secrets you add | `SANDBOXER_API_KEY` | `SANDBOXER_API_KEY` plus a GitHub token with admin access to the repo | | Moving parts | One job, five steps | Three jobs: start runner, run job, delete sandbox | | Good for | Tests that need Docker, `/dev/kvm`, root, or a bigger machine | Whole workflows and marketplace actions you want off GitHub's machines | | What we measured (single runs, 2026-10-27) | Install to tests passed: 26.6 s (the install reused a cached binary) | Runner 2.338.2 downloaded, verified and checked in 8.1 s (not registered) | Both give each job a new Ubuntu 24.04 microVM with its own kernel, Docker and `/dev/kvm` ([machines](/docs/machines)), and both throw it away after the job. ## First: a key for CI Create a key with the `ci` preset and store it as a repository secret. The preset can create sandboxes and delete only the ones it created ([API keys](/docs/api-keys#presets)). ```bash sbx api-key create github-actions --preset ci --ttl 90d ``` Add the secret it prints as `SANDBOXER_API_KEY` under **Settings → Secrets and variables → Actions**. The CLI reads that variable, so the workflow never runs `sbx login`. ## Pattern A: the hosted runner calls sbx ```yaml name: test-in-a-microvm on: [push, pull_request] defaults: run: shell: bash # adds -o pipefail, so a failed download fails the step jobs: test: runs-on: ubuntu-latest timeout-minutes: 20 env: SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }} steps: - uses: actions/checkout@v4 - name: Install sbx run: | curl -fsSL https://sandboxer.com/install | SANDBOXER_INSTALL_DIR="$HOME/.local/bin" sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Create a sandbox run: | id="$(sbx new --name "gha-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" --type default --ttl 1800 --no-env --json | jq -r 'select(.event=="ready") | .id')" echo "SBX_ID=$id" >> "$GITHUB_ENV" - name: Copy the checkout in run: tar -czf - . | sbx ssh "$SBX_ID" -- "mkdir -p repo && tar -xzf - -C repo" - name: Test run: sbx exec "$SBX_ID" --cwd repo --timeout 600 "npm ci && npm test" - name: Delete the sandbox if: always() && env.SBX_ID != '' run: sbx delete "$SBX_ID" --yes ``` What each piece is for: - **`CI` is set on GitHub's runners**, so the installer skips the interactive `sbx onboard`. `SANDBOXER_INSTALL_DIR` plus `$GITHUB_PATH` puts `sbx` on the path of the later steps (not the step that installs it). - **`shell: bash`.** Without it, GitHub runs steps with `bash -e {0}`, which has no `pipefail` ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell)). In one of our runs the install script download returned a 502, and the step still exited 0 because `sh` read an empty pipe. - **`--no-env`** gives the sandbox none of your account's secrets, which matters when it runs pull-request code. Pass what the tests need with `-e KEY=value`. - **`--ttl 1800`** stops the sandbox after 30 minutes if the job dies before the delete step. A stopped sandbox isn't billed ([billing](/docs/billing)), but it does use storage until you delete it or its data is deleted, 14 days after it stopped. - **`sbx exec` exits with the remote exit code** ([CLI](/docs/cli#sbx-exec)), so a failing test fails the step. A synchronous run can wait up to 600 seconds. For longer suites, start the command with `--detach` and follow it with `--attach`, which also exits with the command's code ([long-running jobs](/docs/long-running)). - **The delete step runs on failure too** because of `always()`, and skips if the sandbox was never created. ### What we ran We ran the five `run:` steps above in order, each in its own `bash -e` with `CI=true` and the step's environment files, from a checkout of [commander.js](https://github.com/tj/commander.js) at `ba6d13d`. A `default` sandbox on prod: ```text ### step "Install sbx" exit=0 time=0.1s created sb_dnrpatdkee ### step "Create a sandbox" exit=0 time=7.3s ### step "Copy the checkout in" exit=0 time=2.2s added 204 packages, and audited 205 packages in 3s … > commander@15.0.0 test > node --test && npm run check:type:ts … # tests 1373 # suites 210 # pass 1372 # fail 0 # skipped 1 … ### step "Test" exit=0 time=16.9s ### job time through Test: 26.560575406s ; test rc=0 … ### step "Delete the sandbox" exit=0 time=2.1s ``` Then we added a test that asserts `1 + 1 === 3` and ran `npm test` again in the same sandbox. The step failed, as it should: ```text # tests 1374 # pass 1372 # fail 1 ### step "Test again (expect failure)" exit=1 time=4.8s ``` Every timing on this page is a single run on 2026-10-27. In this run the install step took 0.1 s because the download returned a 502 and the `sbx` from our previous run was still in the install directory; the run before it installed in 5.0 s. That 502 is why the workflow sets `shell: bash` (see below). At $0.036 an hour for `default`, billed per second, a job like this costs a fraction of a cent. ### Why tar and not `sbx scp -r` Our first attempt copied the checkout with `sbx scp -r . "$SBX_ID:/home/user/repo"`. Two problems showed up: | | `sbx scp -r` | `tar` over `sbx ssh` | | - | - | - | | Time for this repo (246 files, 1.9 MB) | 134.5 s | 2.2 s | | Symlinks | Followed: copied as regular files | Kept as links | | commander.js tests | `fail 1` (a symlinked fixture broke) | `fail 0` | `scp` copies the target of a symlink, so `tests/fixtures/another-dir/pm` arrived as a file and its relative import failed. With tar it stays a link: ```text lrwxrwxrwx 1 user user 15 Oct 27 02:22 repo/tests/fixtures/another-dir/pm -> ../other-dir/pm ``` For a public repository, you can skip the copy and clone inside the sandbox. With commander.js at `ba6d13d` in place of the two variables, this took 15.6 s including `npm ci` and the full test run, with `fail 0` (we also passed `--no-audit --no-fund` to `npm ci`): ```bash sbx exec "$SBX_ID" --timeout 600 "git clone -q https://github.com/$GITHUB_REPOSITORY repo && cd repo && git checkout -q $GITHUB_SHA && npm ci && npm test" ``` For private repositories, use tar: the checkout is already on the runner, so the sandbox needs no GitHub credentials. See [moving files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) for the other options. ## Pattern B: an ephemeral self-hosted runner in a sandbox GitHub's self-hosted runner takes an `--ephemeral` flag at registration. GitHub then de-registers the runner after it has processed one job, and recommends ephemeral runners for autoscaling ([GitHub docs](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#ephemeral-runners-for-autoscaling)). One sandbox per job, deleted afterwards, fits that model. Registering needs a registration token from `POST /repos/{owner}/{repo}/actions/runners/registration-token`. The caller needs admin access to the repository, a classic token needs the `repo` scope, and the token expires after one hour ([REST docs](https://docs.github.com/en/rest/actions/self-hosted-runners#create-a-registration-token-for-a-repository)). The job's built-in `GITHUB_TOKEN` can't do this, so store a separate token as `RUNNER_ADMIN_TOKEN`. ```yaml name: test-on-a-sandbox-runner on: [push] defaults: run: shell: bash jobs: start-runner: runs-on: ubuntu-latest outputs: sandbox: ${{ steps.start.outputs.sandbox }} env: SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }} GH_TOKEN: ${{ secrets.RUNNER_ADMIN_TOKEN }} steps: - run: | curl -fsSL https://sandboxer.com/install | SANDBOXER_INSTALL_DIR="$HOME/.local/bin" sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - id: start run: | token="$(gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runners/registration-token" --jq .token)" id="$(sbx new --name "runner-$GITHUB_RUN_ID" --ttl 3600 --no-env --json | jq -r 'select(.event=="ready") | .id')" echo "sandbox=$id" >> "$GITHUB_OUTPUT" sbx ssh "$id" -- bash -s <` makes sure the `test` job lands on the runner this run started, not on another run's. The runner runs as the sandbox's normal `user` with passwordless `sudo`. `--detach` keeps `run.sh` running after the CLI returns. As an alternative to registration tokens, GitHub can create [just-in-time runners](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-a-repository) through the REST API. ### What we ran We can't run GitHub Actions from our test setup, so we checked the sandbox side: download the runner, verify its checksum, and confirm it starts on the sandbox image. We didn't register it with a repository. ```text runner.tar.gz: OK --- run.sh --version 2.338.2 Runner listener exit with 0 return code, stop the service, no retry needed. Exiting runner... --- config.sh --version 2.338.2 --- config.sh --help … Config Options: --unattended Disable interactive prompts for missing arguments. Defaults will be used for missing options --url string Repository to add the runner to. Required if unattended --token string Registration token. Required if unattended --name string Name of the runner to configure (default rugged-plucky-easel) --labels string Custom labels that will be added to the runner. This option is mandatory if --no-default-labels is used. … --disableupdate Disable self-hosted runner automatic update to the latest released version` --ephemeral Configure the runner to only take one job and then let the service un-configure the runner after the job finishes (default false) ``` The runner's .NET listener started without installing any extra packages. Download, checksum and the three checks took 8.1 s. We did not run the registration-token call, `config.sh` against a repository, the detached `run.sh`, or a job on the runner. ### Public repositories GitHub says self-hosted runners "should almost never be used for public repositories", because anyone can open a pull request against them ([GitHub security guide](https://docs.github.com/en/actions/reference/security/secure-use#hardening-for-self-hosted-runners)). A fresh microVM per job keeps one job from affecting the next. It doesn't protect what that job can reach: the runner's token, your network and any secrets you pass in. For untrusted pull requests, prefer pattern A: the steps run on GitHub's runner, only the commands you pass reach the microVM, and `--no-env` keeps your account's secrets out of it. ## Matrix builds and limits Each matrix leg that creates a sandbox uses one start and one active slot. The $20 plan allows 100 at once and 12 starts per minute ([limits](/docs/limits), [billing](/docs/billing#plans)). Use [`strategy.max-parallel`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstrategymax-parallel) to stay under them. Over the limit, `sbx new` fails with `429 rate_limited` or `429 limit_reached`. Check your headroom with `sbx limits --json`. To skip slow setup in every job, prepare one sandbox, save it as a named snapshot, and start jobs with `sbx new --from ` ([templates](/guides/turn-a-sandbox-into-a-reusable-template)). ## FAQ ### How do I add a runner to GitHub Actions? Download the runner from GitHub, run `./config.sh --url --token `, then `./run.sh`. Add `--ephemeral` so it takes one job and is removed afterwards. GitHub's [add runners](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) page shows where to get the token in the repository settings. ### Does a failing test fail the GitHub step? Yes. `sbx exec` exits with the remote command's exit code. In our run, one failing test gave `exit=1`, which fails the step and the job. ### What happens to the sandbox if the job is cancelled? The delete step has `if: always()`, so it runs on cancellation too. If the runner itself dies, `--ttl` stops the sandbox at the deadline. Stopped sandboxes aren't billed, but delete them so they don't use storage. ### Can the tests use Docker or KVM? Yes. Every sandbox has Docker with Compose and `/dev/kvm`, so `docker compose up` and nested VMs work inside the job. See [Docker and KVM inside a sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox). ### Which size should I pick? We ran these tests on `default` (4 vCPU, 8 GB). `small` (2 vCPU, 4 GB) uses machine time at half the rate. `large` and `xlarge` suit heavy builds, and `xlarge` needs the $100 plan or higher ([machines](/docs/machines#sizes)). ## Related - [API keys](/docs/api-keys) - [CLI reference](/docs/cli) - [Limits and errors](/docs/limits) - [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request) --- # How Do I Run SWE-bench-Style Evaluations on MicroVMs? Source: https://sandboxer.com/guides/run-swe-bench-style-evaluations-on-microvms. Updated 2026-10-27. **Short answer:** Run the official SWE-bench harness inside a Sandboxer sandbox. Each sandbox is a microVM with its own Docker daemon, so the harness pulls the per-task image, applies the candidate patch, runs the task's tests and grades FAIL_TO_PASS and PASS_TO_PASS. Save one prepared sandbox as a snapshot and start one VM per task. ## What a SWE-bench task is A [SWE-bench](https://www.swebench.com/) task is a real GitHub issue with the repository frozen at the commit before the fix. The [dataset card](https://huggingface.co/datasets/SWE-bench/SWE-bench_Lite) defines the fields that grading uses: | Field | What it holds | | - | - | | `base_commit` | The repository state before the fix | | `patch` | The gold patch: the fix the real pull request made, without its test changes | | `test_patch` | The tests that pull request added | | `FAIL_TO_PASS` | Tests tied to the issue: they fail before the fix and must pass after it | | `PASS_TO_PASS` | Tests that pass before the fix and must still pass after it | A candidate patch, usually from a model, **resolves** the task only if every `FAIL_TO_PASS` test passes and no `PASS_TO_PASS` test breaks. The [evaluation guide](https://www.swebench.com/SWE-bench/guides/evaluation/) describes the harness that checks this. It runs each task in its own Docker container, so the machine running it needs a working Docker daemon. That is why a microVM fits. A Sandboxer sandbox has Docker preinstalled and its own kernel, so the harness runs unchanged, and nothing a patch or test does reaches your machine or another task. ## What we ran We used one real SWE-bench Lite task, `sympy__sympy-20590`. Its `test_patch` adds `test_immutable` to `sympy/core/tests/test_basic.py`, which checks that a SymPy `Basic` object has no `__dict__` and refuses new attributes. That is the task's one `FAIL_TO_PASS` test. The 21 other tests in that file are its `PASS_TO_PASS` tests. We didn't call a model for this page. We graded three candidate patches instead: | Candidate | Where it came from | | - | - | | `gold` | The dataset's own fix (`-p gold`): it adds `__slots__ = ()` to the `Printable` mixin | | `handmade` | Written by hand: a `__setattr__` guard on `Printable` that refuses attributes not declared in any `__slots__` | | `noop` | Written by hand: adds one comment line and changes nothing | In a real run, the candidate is your agent's diff. Everything else ran on prod as shown. Every time on this page is a single run on 2026-10-27, not a benchmark. ## 1. Install the harness in a sandbox Use `large` (8 vCPU, 16 GB, 125 GB disk). Task images are a few GB each, and the harness runs several containers at once. ```bash sbx new --name aeo-swe-1 --type large --ttl 7200 id=sb_2y6ove8cl5 # the id sbx new printed sbx exec $id "python3 --version; docker --version; df -h / | tail -1" sbx exec $id --timeout 600 "python3 -m venv ~/swe && ~/swe/bin/pip install -q swebench && ~/swe/bin/pip show swebench | head -2" ``` ```text Python 3.12.3 Docker version 29.9.0, build f415da8 /dev/root 130G 5.5G 125G 5% / Name: swebench Version: 5.0.2 ``` The sandbox was ready 3.2 s after `sbx new`, and the install took 41 s. ## 2. Grade the gold patch The harness can run for minutes on bigger tasks, so start it detached and read the log: ```bash sbx exec $id --detach "mkdir -p ~/eval && cd ~/eval && s=\$(date +%s); \ ~/swe/bin/python -m swebench.harness.run_evaluation \ --dataset_name SWE-bench/SWE-bench_Lite --instance_ids sympy__sympy-20590 \ --predictions_path gold --max_workers 1 --run_id gold1 > run1.log 2>&1; \ echo \"exit=\$? seconds=\$((\$(date +%s)-s))\" >> run1.log" sbx exec $id "tail -c 1500 ~/eval/run1.log" ``` ```text … All instances run. Total instances: 1 Instances submitted: 1 Instances completed: 1 Instances incomplete: 0 Instances resolved: 1 Instances unresolved: 0 … Report written to gold.gold1.json exit=0 seconds=66 ``` The per-task report shows the grading: ```bash sbx exec $id "tail -20 ~/eval/logs/run_evaluation/gold1/gold/sympy__sympy-20590/run_instance.log" ``` ```text … 2026-10-27 02:35:21,569 - INFO - Test runtime: 3.44 seconds … 2026-10-27 02:35:21,618 - INFO - report: {'sympy__sympy-20590': {'patch_is_None': False, 'patch_exists': True, 'patch_successfully_applied': True, 'resolved': True, … 'tests_status': {'FAIL_TO_PASS': {'success': ['test_immutable'], 'failure': []}, 'PASS_TO_PASS': {'success': ['test__aresame', 'test_structure', 'test_equality', …], 'failure': []}, …}}} Result for sympy__sympy-20590: resolved: True ``` `docker images` inside the sandbox then listed `swebench/sweb.eval.x86_64.sympy_1776_sympy-20590`, 2.77 GB. The harness pulled a prebuilt image for the task instead of building one, which took 38 s of the 66. ## 3. Grade your own patches A predictions file is a JSON list with one entry per task. To get a diff that applies cleanly, make the edit inside the task's own image (the repository is at `/testbed`) and print `git diff`: ```bash sbx exec $id "cd ~/eval && docker run --rm swebench/sweb.eval.x86_64.sympy_1776_sympy-20590 bash -c \"cd /testbed && sed -i \\\"s/^class Printable:/# Printing mixin for Basic and friends.\\nclass Printable:/\\\" sympy/core/_print_helpers.py && git diff\" > noop.diff && cat noop.diff" ``` ```diff diff --git a/sympy/core/_print_helpers.py b/sympy/core/_print_helpers.py index 96d7e72676..69c96086f9 100755 --- a/sympy/core/_print_helpers.py +++ b/sympy/core/_print_helpers.py @@ -5,6 +5,7 @@ but cannot be defined there without causing circular imports. """ +# Printing mixin for Basic and friends. class Printable: """ The default implementation of printing for SymPy classes. ``` Wrap the diff in a predictions file and grade it: ```bash sbx exec $id "cd ~/eval && python3 -c \"import json; json.dump([{\\\"instance_id\\\": \\\"sympy__sympy-20590\\\", \\\"model_name_or_path\\\": \\\"noop\\\", \\\"model_patch\\\": open(\\\"noop.diff\\\").read()}], open(\\\"noop.json\\\", \\\"w\\\"))\"" sbx exec $id --timeout 600 "cd ~/eval && ~/swe/bin/python -m swebench.harness.run_evaluation \ -d SWE-bench/SWE-bench_Lite -i sympy__sympy-20590 -p noop.json --max_workers 1 --run_id noop1 \ 2>&1 | grep -E '^Instances (resolved|unresolved)'" ``` ```text Instances resolved: 0 Instances unresolved: 1 ``` Reading `report.json` for each candidate gives the whole picture: | Candidate | `FAIL_TO_PASS` | `PASS_TO_PASS` | Resolved | Task time | | - | - | - | - | - | | `gold` (cold, image pulled) | 1 of 1 passed | 21 of 21 passed | Yes | 66 s | | `gold` (image cached) | 1 of 1 passed | 21 of 21 passed | Yes | 27 s | | `noop` | `test_immutable` failed | 21 of 21 passed | No | 27 s | | `handmade` | No tests ran | No tests ran | No | 23 s | The `noop` row is the case `FAIL_TO_PASS` exists for: nothing broke, but the issue wasn't fixed. The `handmade` patch applied cleanly (`Applied patch sympy/core/_print_helpers.py cleanly.` in the log), but SymPy itself sets plain attributes on its objects, so `import sympy` failed before any test ran: ```text File "/testbed/sympy/assumptions/assume.py", line 154, in __new__ obj.name = name File "/testbed/sympy/core/_print_helpers.py", line 23, in __setattr__ raise AttributeError(name) AttributeError: name ``` With no test results to parse, the report says `"patch_successfully_applied": false` and has no `tests_status`. Check `test_output.txt` before you blame the diff format. ## Where the time goes From `run_instance.log` for the cached `gold` run (27.2 s from the client): | Step | Time | | - | - | | Harness start and dataset load | About 6 s | | Create the container and apply the patch | 0.5 s | | Run the tests (`bin/test sympy/core/tests/test_basic.py`) | 5.1 s | | Stop and remove the container | 15.2 s | More than half of a cached run is the container shutting down. With `--max_workers` above 1, those waits overlap, so run several tasks per harness call when you batch them in one VM. The harness's own help says to keep `--max_workers` at or below 75% of CPU cores, which is 6 on `large`. ## 4. Fan out: one task per VM Once a sandbox has the harness installed, save it as a named snapshot. Every task then starts from the same bytes, already set up: ```bash sbx snapshot $id aeo-swe-ready sbx delete $id --yes ``` This script runs two tasks at once, each in its own sandbox, and deletes each one as soon as its verdict is read: ```bash #!/bin/bash snap=aeo-swe-ready inst=sympy__sympy-20590 run_task() { # $1 = run id, $2 = predictions (gold or a file in ~/eval), $3 = sandbox name t0=$(date +%s.%N) id=$(sbx new --from "$snap" --name "$3" --type large --ttl 1800 --json | jq -r 'select(.event=="ready") | .id') t1=$(date +%s.%N) sbx exec "$id" --timeout 600 "cd ~/eval && ~/swe/bin/python -m swebench.harness.run_evaluation \ -d SWE-bench/SWE-bench_Lite -i $inst -p $2 --max_workers 1 --run_id $1 > run.log 2>&1" t2=$(date +%s.%N) verdict=$(sbx exec "$id" "python3 -c \"import json,glob; r=json.load(open(glob.glob('eval/logs/run_evaluation/$1/*/$inst/report.json')[0]))['$inst']; t=r['tests_status']; print(json.dumps({'resolved': r['resolved'], 'f2p_failed': t['FAIL_TO_PASS']['failure'], 'p2p_failed': t['PASS_TO_PASS']['failure']}))\"") sbx delete "$id" --yes > /dev/null printf '%s %s start=%.1fs eval=%.1fs %s\n' "$1" "$id" "$(echo "$t1-$t0" | bc)" "$(echo "$t2-$t1" | bc)" "$verdict" } run_task gold gold aeo-swe-2 & run_task noop noop.json aeo-swe-3 & wait ``` The output, from prod: ```text noop sb_hg1olybk8t start=3.2s eval=27.8s {"resolved": false, "f2p_failed": ["test_immutable"], "p2p_failed": []} gold sb_5httmzzxf1 start=3.2s eval=28.3s {"resolved": true, "f2p_failed": [], "p2p_failed": []} ``` Both tasks finished in 33 s of wall time, against about 62 s back to back. Each VM was ready 3.2 s after `sbx new --from`, and neither pulled the image, because the 2.77 GB image was already on the snapshot's disk. In a real run, `$inst` changes per task and the predictions file holds your agent's patch for it. ## Scale it | | One task per VM | Many tasks per VM | | - | - | - | | Isolation | Full: one kernel and one Docker daemon per task | Tasks share one VM, each in its own container | | Starts used | One per task | One per batch | | Disk | One task's image | Every image in the batch, so watch the 125 GB on `large` | | Use it for | Untrusted agent patches, RL rollouts, reruns of one task | Large sweeps on a tight start budget | - **Starts are rate limited.** Creates, resumes and forks each count as one start. The $20 plan allows 12 a minute, 60 an hour and 200 a day, and 100 sandboxes at once. The $100 plan allows 30, 210 and 840, and 300 at once ([billing](/docs/billing#plans)). The 300-task SWE-bench Lite test split, one VM per task, is 300 starts. That is more than the $20 plan's 200 a day, so batch several tasks per VM there. On the $100 plan, 210 starts an hour gets through it in under 2 hours. Check what's left before a burst with `sbx limits --json | jq '.starts'`. A `429 rate_limited` reply carries `Retry-After` and `details.resetsAt`. - **Cost is per second.** `large` costs $0.072/hour, so a one-minute task costs about $0.0012 of machine time. `xlarge` (16 vCPU, 32 GB) needs the $100 plan or higher. - **Pre-pull images into the snapshot.** A cold task here spent 38 s pulling its image, and VMs started from the snapshot skipped that. `docker pull` the images for a batch before `sbx snapshot`. Images count toward the snapshot's disk, so group tasks by repository rather than putting every image in one template. - **Always clean up.** Delete each sandbox after reading its report, and set a short `--ttl` as a backstop. When the sweep is done, run `sbx snapshot rm aeo-swe-ready`. ## FAQ ### Do I need to build the SWE-bench Docker images myself? Not for this task. With swebench 5.0.2, the harness pulled a prebuilt image for `sympy__sympy-20590` from Docker Hub (2.77 GB, 38 s) instead of building it. Building images yourself takes much longer and more disk. ### Why does a task need Docker inside the sandbox? The official harness runs every task in its own container built for that repository and commit. A Sandboxer sandbox is a full VM with its own Docker daemon, so the harness runs as it would on a Linux server, with no host Docker socket shared. ### Which sandbox size should I use? `large` (8 vCPU, 16 GB, 125 GB disk) ran this task with room to spare. Use `default` (4 vCPU, 8 GB, 50 GB) for one small task at a time, and `xlarge` for many workers in one VM. ### How do I grade a model's patch instead of the gold patch? Write a predictions JSON list of `instance_id`, `model_name_or_path` and `model_patch` (a unified diff against `base_commit`), and pass it with `-p`. The harness applies the diff, runs the tests and writes `report.json`. ### Can one task affect another? Not with one task per VM. Each sandbox has its own kernel, disk and Docker daemon, and each starts from the same snapshot, so nothing one task writes reaches the next. ## Related - [Build RL and eval environments with snapshots and forks](/guides/build-rl-and-eval-environments-with-snapshots-and-forks) - [Docker and nested KVM inside an AI agent sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox) - [Snapshots, forks and named templates](/docs/snapshots) - [Limits and errors](/docs/limits) --- # How to Run the Android Emulator in a Cloud VM and Watch It Source: https://sandboxer.com/guides/run-the-android-emulator-in-a-cloud-vm. Updated 2026-10-27. **Short answer:** Create a large Sandboxer sandbox, which has /dev/kvm, install the Android command-line tools and an x86_64 system image, and start the emulator with -accel on. It cold-boots Android 15 in about 30 seconds. Run it headless and drive it with adb, or put its window on the sandbox's desktop and watch it in your browser. ## What we measured We ran every command below on two `large` sandboxes on 2026-10-27. | Step | Result | | - | - | | Size | `large`: 8 vCPU, 16 GB RAM, 125 GB disk, $0.072/hour, billed per second | | `/dev/kvm` | `crw-rw---- root kvm`; `user` is in the `kvm` group | | Java, SDK tools, emulator, Android 15 image | About 1 minute to download and install; 4.6 GB on disk | | Cold boot to `sys.boot_completed=1` | 30–36 seconds (four runs) | | Quick boot from the emulator's own snapshot | 6–19 seconds | | `adb install` of a 263 KB APK | `Success` in under half a second | The emulator's x86_64 guest runs on the sandbox's KVM, so it is a VM inside a VM. Use `large` or `xlarge`: the emulator's RAM comes out of the sandbox's. We didn't test smaller sizes. ## 1. Create the sandbox and check KVM ```bash sbx new --type large --name android sbx exec sb_2l9kjerkuc "ls -l /dev/kvm; id -nG" ``` ```text crw-rw---- 1 root kvm 10, 232 Oct 27 02:39 /dev/kvm user sudo docker kvm ``` The examples use the id `sb_2l9kjerkuc`; use the one `sbx new` printed. ## 2. Install Java and the Android command-line tools The SDK tools need a Java runtime. Download the command-line tools from Google and put the SDK directories on your `PATH` in `~/.profile`, which `sbx exec` reads. ```bash sbx exec sb_2l9kjerkuc --timeout 600 "sudo apt-get update -qq && sudo apt-get install -y -qq openjdk-17-jre-headless" sbx exec sb_2l9kjerkuc --timeout 600 'set -e mkdir -p ~/android/cmdline-tools && cd /tmp curl -fsSLo tools.zip https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip unzip -q tools.zip && mv cmdline-tools ~/android/cmdline-tools/latest cat >> ~/.profile <<"EOF" export ANDROID_HOME=$HOME/android export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH EOF' ``` The zip name changes with each release. Get the current one from the [Android Studio download page](https://developer.android.com/studio). ## 3. Install the emulator and a system image, then create a device ```bash sbx exec sb_2l9kjerkuc --timeout 600 'sdkmanager "platform-tools" "emulator" "system-images;android-35;google_apis;x86_64" echo no | avdmanager create avd -n pixel -k "system-images;android-35;google_apis;x86_64" -d pixel_7 emulator -accel-check' ``` ```text … Unzipping emulator...... Unzipping system-images/android-35/google_apis/x86_64...... … KVM (version 12) is installed and usable. ``` `emulator -accel-check` is the test that matters. If it doesn't say KVM is usable, the emulator falls back to software emulation, which is too slow to use. The current command-line tools print a notice that `sdkmanager` is deprecated in favor of the new `android sdk` command. `sdkmanager` still installed everything; see Google's [Android CLI docs](https://developer.android.com/tools/agents/android-cli) if you want the new syntax. ## 4. Boot it headless and wait for Android Start the adb server first, then the emulator as a background process: ```bash sbx exec sb_2l9kjerkuc "adb start-server" sbx exec sb_2l9kjerkuc --detach "emulator -avd pixel -no-window -no-audio -no-boot-anim -accel on -gpu swiftshader_indirect > ~/emulator.log 2>&1" ``` `-gpu swiftshader_indirect` renders in software, since the sandbox has no GPU. Then wait until Android reports that it finished booting: ```bash sbx exec sb_2l9kjerkuc --timeout 300 'adb wait-for-device until [ "$(adb shell getprop sys.boot_completed)" = 1 ]; do sleep 2; done grep "Boot completed" ~/emulator.log' ``` ```text INFO | Boot completed in 30068 ms ``` ## 5. Drive it with adb ```bash sbx exec sb_2l9kjerkuc 'adb devices; adb shell getprop ro.build.version.release; adb shell getprop ro.product.model' ``` ```text List of devices attached emulator-5554 device 15 sdk_gphone64_x86_64 ``` Install an APK, launch it and take a screenshot. This uses a small open-source text editor from F-Droid: ```bash sbx exec sb_2l9kjerkuc --timeout 120 'curl -fsSLo /tmp/editor.apk https://f-droid.org/repo/org.billthefarmer.editor_198.apk adb install /tmp/editor.apk adb shell monkey -p org.billthefarmer.editor -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 sleep 3; adb shell dumpsys activity activities | grep -m1 topResumedActivity adb exec-out screencap -p > ~/screen.png && file ~/screen.png' sbx scp sb_2l9kjerkuc:/home/user/screen.png . ``` ```text Performing Streamed Install Success topResumedActivity=ActivityRecord{4cd5e13 u0 org.billthefarmer.editor/.Editor t8} /home/user/screen.png: PNG image data, 1080 x 2400, 8-bit/color RGBA, non-interlaced ``` Your own test APKs install the same way, and an agent can run all of this through `sbx exec` or the exec API. ## 6. Watch it on the sandbox's desktop Every sandbox has a streamed Linux desktop. Open it once so it starts, quit the headless emulator, and start it again with a window on the desktop's X display, `:1`: ```bash sbx desktop sb_2l9kjerkuc sbx exec sb_2l9kjerkuc "adb emu kill" sbx exec sb_2l9kjerkuc --detach "DISPLAY=:1 emulator -avd pixel -no-audio -accel on -gpu swiftshader_indirect > ~/emulator.log 2>&1" ``` The emulator window opens on the desktop next to Chrome, and you can click and type in it from your browser tab. The emulator also opens a dialog saying it is running under nested virtualization and that this is not recommended. Close it; in our tests the emulator booted and ran with the dialog open. Check the window from the command line with `DISPLAY=:1 xdotool search --name "Android Emulator" getwindowname`, which printed `Android Emulator - pixel:5554`. The desktop URL carries a secret token and works for ten minutes. See [Desktop](/docs/desktop) for how sharing and embedding work. ## 7. Stop and resume: quit the emulator first `adb emu kill` quits the emulator and saves a quick-boot snapshot of the device. Do that before you stop the sandbox, then start the emulator again after you resume: ```bash sbx exec sb_2l9kjerkuc --timeout 120 'adb emu kill; while pgrep qemu-system-x86 >/dev/null; do sleep 1; done; echo emulator exited' sbx stop sb_2l9kjerkuc --wait sbx resume sb_2l9kjerkuc sbx exec sb_2l9kjerkuc "adb start-server" sbx exec sb_2l9kjerkuc --detach "emulator -avd pixel -no-window -no-audio -no-boot-anim -accel on -gpu swiftshader_indirect > ~/emulator.log 2>&1" ``` ```text INFO | Successfully loaded snapshot 'default_boot' using 4534 ms ``` Android was booted again, with the installed app, 6 seconds after the emulator started (19 seconds the first time we tried it). In our test, an emulator left running through a stop did not respond after resume, so don't skip `adb emu kill`. The SDK, the device and its data are on the sandbox's disk, so they are kept either way. To skip the install on every new sandbox, save a prepared one as a named snapshot and start new sandboxes from it. See [snapshots](/docs/snapshots). ## FAQ ### Can I run Android Studio in the cloud? You can install it on a sandbox and use it on the streamed desktop, because the sandbox is a full Ubuntu VM with `/dev/kvm`. We tested the emulator and command-line tools, not the Android Studio IDE. ### Does the Android emulator need KVM? For usable speed, yes. Without hardware acceleration, an x86_64 image runs in software emulation. Run `emulator -accel-check`: on a Sandboxer sandbox it prints `KVM (version 12) is installed and usable.` ### Can I use ARM system images? We used an x86_64 image, which runs with KVM acceleration on the sandbox's x86 CPU. We didn't test ARM images. ### How long does the emulator take to boot? In our tests on `large`, a cold boot to `sys.boot_completed=1` took 30 to 36 seconds. A quick boot from the emulator's saved snapshot took 6 to 19 seconds. ### Can I run more than one emulator? We tested one per sandbox. For parallel tests, the simplest path is one sandbox per emulator, all started from the same named snapshot. ## Related - [Docker and nested KVM inside an agent sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox) - [Run a computer-use agent on a Linux desktop](/guides/run-a-computer-use-agent-on-a-linux-desktop) - [Machines and sizes](/docs/machines) - [Desktop](/docs/desktop) --- # Can You Fork a Running Cloud Sandbox, Memory and All? Source: https://sandboxer.com/guides/fork-a-running-sandbox-with-memory. Updated 2026-10-27. **Short answer:** No. sbx fork copies a running sandbox's disk into a new, independent sandbox in a few seconds, but the copy boots fresh, so no RAM and no running processes come across. Files written up to the fork are there. To keep memory, stop and resume the same sandbox, or run long-lived work as systemd services that save state to disk. ## What a fork copies | | Copied to the fork | Not copied | | - | - | - | | Disk | Everything: `/home/user`, `/etc`, installed packages, Docker images, enabled systemd units | | | Memory | | RAM, open files, sockets, shell sessions | | Processes | Enabled systemd services start at boot | Anything started by hand, with `exec --detach`, or in `tmux` | | Settings | Size, environment version and per-sandbox env (unless you override them) | The source's TTL: the fork gets the default one hour | | Identity | | Id, subdomain, hostname, machine-id and SSH host keys are all new | | Hosted ports | | Routes and tokens: run `sbx host` on the fork | The source is never touched and keeps running. The fork's disk is a copy-on-write reflink, so it takes about the same time for a large disk as for a small one, and uses no extra storage until the two diverge. ## Try it: a counter in memory, then a fork We ran this on prod on 2026-10-27 with a `small` sandbox. The first process keeps its count only in memory and prints it to a log. The second is a systemd service that saves its count to a file each second. ```bash sbx new --type small --name aeo-fork-1 src=sb_wq41n3fyb5 # the id sbx new printed # 1. A counter that lives in memory (it logs each value, plus a random token picked at start) sbx exec $src --detach "python3 -u -c 'import time,itertools,secrets;tok=secrets.token_hex(4);[print(i,tok) or time.sleep(1) for i in itertools.count()]' > ~/c.log" # 2. A counter that saves its value to disk, as an enabled systemd service sbx exec $src "sudo tee /etc/systemd/system/counter.service >/dev/null <<'U' [Unit] Description=counter that saves its value to disk [Service] User=user ExecStart=/bin/bash -c 'n=\$(cat /home/user/n 2>/dev/null || echo 0); while true; do n=\$((n+1)); echo \$n > /home/user/n; sleep 1; done' Restart=always [Install] WantedBy=multi-user.target U sudo systemctl daemon-reload && sudo systemctl enable --now counter.service" ``` Just before the fork, both counters were at 254, and we wrote the time to a file: ```bash sbx exec $src 'tail -1 ~/c.log; echo n=$(cat ~/n); date -u +%T.%N | cut -c1-12 > ~/marker2; cat ~/marker2' ``` ```text 254 cefc0bd0 n=254 02:05:36.141 ``` Then we forked, at a bigger size, and timed it: ```bash time sbx fork $src --type default --wait --json ``` ```text {"event":"forking","id":"sb_8lcd3t2t2o","source":"sb_wq41n3fyb5","sandbox":{…"memoryRestored":null,…}} {"event":"state","id":"sb_8lcd3t2t2o","state":"provisioning"} {"event":"ready","id":"sb_8lcd3t2t2o","state":"ready","url":"https://regal-daring-cricket.box.sandboxer.com",…} real 0m3.354s ``` ## What the fork had ```bash c=sb_8lcd3t2t2o sbx exec $c 'nproc; cat ~/marker2; tail -1 ~/c.log; pgrep -a python3 || echo "no python3"; echo n=$(cat ~/n); systemctl is-active counter; uptime -s' ``` ```text 4 02:05:36.141 254 cefc0bd0 no python3 n=257 active 2026-10-27 02:05:38 ``` Line by line: - **4 vCPUs:** `--type default` took effect, so the fork is bigger than its `small` source. - **The marker file is there.** It was written one second before the fork, and the source listed no snapshots at that point. The fork copied the disk as it was when we forked. - **The log ends at 254, and `python3` is gone.** The log file came across on the disk, but the process that wrote it, and the count in its memory, did not. The fork booted at 02:05:38. - **The systemd counter reads 257.** The service started at boot, read 254 from disk and kept counting. This is how to carry work into a fork. The source was unaffected. A few seconds later it showed `258 cefc0bd0` with the same `python3` process (PID 527) still running. `sbx info` on the fork shows `"memoryRestored": null` and `"resumeReason": null`. A fork never restores memory, so that field has nothing to report. It's set only on a resume. ## How long a fork takes These are single runs on prod on 2026-10-27, not benchmarks: | Run | Measured | Result | | - | - | - | | `small` to `small` | `createdAt` to `updatedAt` once ready, from `sbx info` | 1.9 seconds | | `small` to `default`, `sbx fork --wait` | Wall time with `time`, including the CLI | 3.4 seconds | `sbx fork` without `--wait` returns the new id at once (HTTP 202). Forks count as a start against your plan's start limits, like a create or a resume. ## If you need the memory too Use [stop and resume](/guides/pause-and-resume-a-sandbox-with-memory) instead of a fork. A stop writes the VM's memory to disk, and a resume brings the same sandbox back with its processes running. It's one sandbox, not a copy. A resume restores memory only on a machine with the same CPU type. If none is free within 2 minutes, it boots from disk instead: files are kept, running processes are not, and the sandbox shows `memoryRestored: false` with `resumeReason: "cpu_mismatch"`. Pass `sbx resume --require-memory` to wait for a matching machine rather than boot from disk. For copies that should come up working, make your services systemd units with their state on disk, as above. Or save a named snapshot once (`sbx snapshot $src my-stack`) and start each copy with `sbx new --from my-stack`. ## FAQ ### Does the fork get the processes that were running? No. The fork boots from a copy of the disk, so only enabled systemd services start. Processes you started by hand, with `sbx exec --detach`, or in `tmux` are not in the fork. ### Is the fork's disk from the last 10-minute snapshot? In our test, no. A file written one second before the fork was in the fork, and the source had no snapshots listed yet. The fork copied the running sandbox's disk at the moment of the fork. ### Can I change the size when I fork? Yes. Pass `--type small`, `default`, `large` or `xlarge`. We forked a `small` sandbox to `default` and the fork had 4 vCPUs. ### Does forking stop or slow down the source? It didn't in our test. The source's in-memory counter kept going through the fork with the same PID. The source is never changed. ### Can I fork a sandbox created with `--no-snapshots`? No. The API answers `409 snapshots_disabled`. Such a sandbox can't be resumed, forked or saved as a template either. ## Related - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Snapshots: stop, resume, fork and templates](/docs/snapshots) - [Run coding agents in parallel](/guides/run-coding-agents-in-parallel) - [Long-running work and systemd services](/docs/long-running) --- # How to Move Files In and Out of a Cloud Sandbox Source: https://sandboxer.com/guides/move-files-in-and-out-of-a-sandbox. Updated 2026-10-27. **Short answer:** Use sbx scp for files and folders of any size, in either direction. From code, the SDK and MCP read and write single files up to 5 MiB, and the SDK downloads up to 50 MiB. Clone repos inside the sandbox with git, and pull files from a stopped sandbox's snapshot without resuming it. ## Which method to use | Method | Direction | Best for | Limits | | - | - | - | - | | `sbx scp` | Both | Single files and whole folders, large files | No size cap from Sandboxer; limited by your bandwidth and the sandbox's disk | | `rsync` over the CLI tunnel | Both | Syncing a folder again and again (sends only changes) | Same as `scp` | | SDK `files.write` / `read` / `readBytes` | Both | Config, inputs and results from your code | 5 MiB per file (`413 file_too_large` above that) | | SDK `files.download` | Out | Build artifacts and archives from code | 50 MiB per file | | MCP `write_file` / `read_file` | Both | An agent moving text or small binaries (base64) | 5 MiB per file | | `git clone` inside the sandbox | In | Source code, at the sandbox's own network speed | None from Sandboxer | | Hosted port | Out | A download link for a browser or another machine | Anyone with the URL can fetch while it's hosted | | `sbx snapshot pull` | Out | Getting files from a stopped sandbox without resuming it | Whole snapshot or one `--path` | The files API and the SDK work on paths under `/home/user` or `/tmp`. Relative paths start at `/home/user`. ## sbx scp: files and folders, both directions `sbx scp` runs your installed OpenSSH `scp` through the same authenticated tunnel as `sbx ssh`, so it works behind any firewall and needs no key setup. Use `:/path` as the remote side. ```bash id=sb_yke1vvb5ca # Upload a file and a folder (51 files) sbx scp ./data.csv $id:/home/user/ sbx scp -r ./my-project $id:/home/user/ sbx exec $id "ls ~ && ls ~/my-project/src | wc -l" # data.csv # my-project # 51 # Download a file and a folder sbx scp $id:/home/user/out/results.tar.gz ./ sbx scp -r $id:/home/user/out/reports ./ ls reports # r1.txt r2.txt r3.txt ``` When both ends have `tar`, a recursive copy to a new directory is sent as one tar stream, which is much faster for many small files. Large files go the same way. A 100 MiB file of random bytes took under 10 seconds from our test machine (one measured run on prod, 2026-10-27): ```bash head -c 104857600 /dev/urandom > big.bin time sbx scp ./big.bin $id:/tmp/ # real 0m9.575s ``` Your own upload speed sets the pace, so expect a 1 GB file to take roughly ten times as long on the same connection. ## rsync: sync a folder repeatedly For a folder you change and push again and again, `rsync` sends only what changed. `sbx ssh-config` prints an SSH config stanza for the pseudo-host `*.sbx`, so any OpenSSH tool can reach a sandbox by id. Append it to `~/.ssh/config` with `sbx ssh-config --install`, then: ```bash rsync -av ./my-project/ $id.sbx:my-project/ # sending incremental file list # README.md # # sent 1,061 bytes received 42 bytes 441.20 bytes/sec ``` Only `README.md`, the one file we edited, was sent. The same stanza works for VS Code Remote-SSH (`sb_yke1vvb5ca.sbx`). ## SDK: read and write files from code The TypeScript SDK (`npm i sandboxer-sdk`) reads `SANDBOXER_API_KEY` from the environment. This script ran against a live sandbox: ```typescript import { Sandboxer } from "sandboxer-sdk"; import { readFileSync, writeFileSync } from "node:fs"; const sandbox = await Sandboxer.get("sb_yke1vvb5ca"); // Write text and bytes (parent directories are created) await sandbox.files.write("inbox/config.json", JSON.stringify({ retries: 3 })); await sandbox.files.write("inbox/data.csv", readFileSync("data.csv")); // Read them back console.log(await sandbox.files.read("inbox/config.json")); const bytes = await sandbox.files.readBytes("inbox/data.csv"); console.log("data.csv:", bytes.length, "bytes"); // Download a larger artifact (up to 50 MiB) const tarball = await sandbox.files.download("out/results.tar.gz"); writeFileSync("results.tar.gz", tarball); console.log("results.tar.gz:", tarball.length, "bytes"); ``` ```text {"retries":3} data.csv: 24 bytes results.tar.gz: 182 bytes ``` The Python SDK has the same calls: `files.write`, `files.read`, `files.read_bytes` and `files.download`. ### What happens at the limits We tried each limit. Errors arrive as a `SandboxerError` with status and code: ```text write 6 MiB: 413 file_too_large Files may be at most 5 MiB; use scp for larger files. readBytes 20 MiB: 413 file_too_large Files over 5 MiB: use GET /sandboxes/{sandboxId}/artifacts (50 MiB) or scp. download 20 MiB: 20971520 bytes download 60 MiB: 413 file_too_large Artifacts may be at most 50 MiB; use scp for larger files. ``` So: up to 5 MiB, use `read`/`write`. Up to 50 MiB out, use `download`. Anything bigger, or anything going in over 5 MiB, use `sbx scp`. ## MCP: files for an agent An agent connected to the [Sandboxer MCP server](/docs/mcp) gets `write_file` and `read_file`, each up to 5 MiB. Text is `utf8` by default; pass `"encoding": "base64"` for binary. These are real results from the local `sbx mcp` server (each call also passed `sandboxId`, left out here): ```json {"name": "write_file", "arguments": {"path": "notes/todo.md", "content": "- ship the guide\n"}} → "Wrote 17 bytes to /home/user/notes/todo.md." {"name": "write_file", "arguments": {"path": "notes/pixel.bin", "content": "AAEC/w==", "encoding": "base64"}} → "Wrote 4 bytes to /home/user/notes/pixel.bin." {"name": "read_file", "arguments": {"path": "notes/pixel.bin", "encoding": "base64"}} → "AAEC/w==" ``` Without `sandboxId`, the tools use the sandbox this MCP session created or used last. ## git clone inside the sandbox Source code doesn't need to pass through your machine at all. Clone it from inside: ```bash sbx exec $id --timeout 120 "git clone --depth 1 https://github.com/expressjs/express.git && du -sh express" # Cloning into 'express'... # 2.0M express ``` For private GitHub repos, attach them to an [environment](/docs/environments) with `sbx env add-repo base acme/web --branch main`. New sandboxes clone them into `/home/user/` with a token, so `git push` works too when the environment passes GitHub credentials. ## Download through a hosted port To hand a file to a browser or another machine, serve it and [host the port](/docs/hosting): ```bash sbx exec $id --detach "cd out && python3 -m http.server 8000" sbx host $id 8000 --public # "url":"https://hidden-chief-hare-8000.box.sandboxer.com" curl -sS -o results.tar.gz -w "HTTP %{http_code} %{size_download} bytes\n" \ https://hidden-chief-hare-8000.box.sandboxer.com/results.tar.gz # HTTP 200 182 bytes sbx host hide $id 8000 # take the URL down; the server keeps running ``` Give the server a second to start before the first request. Without `--public` the URL is private and carries a `_token`: treat it like a password. After `sbx host hide`, the same URL returned 404. ## Get files from a stopped sandbox A stopped sandbox costs nothing, and you don't have to resume it to get files out. Every stop writes a snapshot. Find it, list it and pull just what you need: ```bash sbx stop $id sbx snapshot latest $id # "id":"46b816c3-…","trigger":"stop" sbx snapshot tree 46b816c3-… --path out # 9 B out/reports/r1.txt # 9 B out/reports/r2.txt # 9 B out/reports/r3.txt # 182 B out/results.tar.gz sbx snapshot pull 46b816c3-… --path my-project -o ./restore # ✓ Pulled 52 files (373 B) into ./restore ``` Leave out `--path` to download the whole snapshot (`home_user/` and `docker/`). Tree paths are relative to `/home/user`. ## FAQ ### What is the largest file I can upload to a sandbox? `sbx scp` and `rsync` have no Sandboxer size limit; the sandbox's disk and your bandwidth are the limits. The SDK, the files API and MCP accept up to 5 MiB per file and return `413 file_too_large` above that. ### Can I upload binary files through MCP or the SDK? Yes. The SDK sends bytes as base64 for you when you pass a `Uint8Array` or `Buffer`. With MCP, pass `"encoding": "base64"` to `write_file` and `read_file`. ### Can I download files without resuming a stopped sandbox? Yes. `sbx snapshot latest ` finds the snapshot the stop wrote, `sbx snapshot tree ` lists its files, and `sbx snapshot pull --path ` downloads them. The sandbox stays stopped and isn't billed. ### Which paths can the files API reach? Paths that resolve under `/home/user` or `/tmp`. Relative paths start at `/home/user`. For anything else, use `sbx scp` or `sbx exec`. ### Is there a file browser? Yes. The dashboard has a file browser with upload and download on every sandbox page. ## Related - [SSH and file transfer](/docs/ssh) - [Snapshots](/docs/snapshots) - [TypeScript SDK](/docs/sdk-typescript) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # How Do I Turn a Sandbox Into a Reusable Template? Source: https://sandboxer.com/guides/turn-a-sandbox-into-a-reusable-template. Updated 2026-10-27. **Short answer:** Set up one sandbox the way you want it, then save its disk under a name with sbx snapshot, passing the sandbox id and the name. Every sbx new --from with that name starts a new sandbox with your packages and files already in place. sbx snapshot rm deletes the template. ## At a glance | Step | Command | | - | - | | Save a template | `sbx snapshot ` | | List your templates | `sbx snapshots` | | Show one | `sbx snapshot info ` | | Start a sandbox from it | `sbx new --from ` | | Delete it | `sbx snapshot rm ` | A template on Sandboxer is a **named snapshot**: a saved copy of a sandbox's whole disk. It is not tied to the sandbox it came from, and it is kept until you remove it. ## Try it: install once, start many We ran every command below on prod on 2026-10-27 with a real `small` sandbox. Output is trimmed with `…`. ### 1. Set up a sandbox Create a sandbox, install a package and write a file: ```bash sbx new --name aeo-tpl-1 --type small ``` ```text ✓ sb_cauu1dfagw is ready Type: small (2 vCPU, 4 GB) … ``` ```bash sbx exec sb_cauu1dfagw 'sudo apt-get update -qq && sudo apt-get install -y -qq figlet' sbx exec sb_cauu1dfagw 'mkdir -p ~/app && echo greeting=hello-from-template > ~/app/config.txt' ``` ### 2. Save it as a named snapshot ```bash sbx snapshot sb_cauu1dfagw aeo-tpl-figlet ``` ```text ✓ Saved sb_cauu1dfagw as aeo-tpl-figlet (20.1 GB) Deploy it with `sbx new --from aeo-tpl-figlet`. ``` Saving from a running sandbox takes a fresh snapshot first. The CLI waits until the template is `ready`. Snapshots are copy-on-write reflinks on the node's filesystem, so saving doesn't copy the whole disk. ```bash sbx snapshot info aeo-tpl-figlet ``` ```text Name: aeo-tpl-figlet Status: ready Source: sb_cauu1dfagw Type: small Size: 20.1 GB … ``` ### 3. Delete the source (optional) A named snapshot is independent of its source. To prove it, we deleted the original sandbox before using the template: ```bash sbx delete sb_cauu1dfagw --yes ``` ### 4. Start a new sandbox from the template ```bash sbx new --from aeo-tpl-figlet ``` ```text ✓ sb_zxfrwgmiay is ready Type: small (2 vCPU, 4 GB) … ``` This took about 4 seconds from command to `ready` (one measured run on prod, 2026-10-27). The new sandbox took the template's size (`small`) because we passed no `--type`. ### 5. Check the install and the file ```bash sbx exec sb_zxfrwgmiay 'which figlet && cat ~/app/config.txt && figlet -f small ok' ``` ```text /usr/bin/figlet greeting=hello-from-template _ ___| |__ / _ \ / / \___/_\_\ ``` The package installed with apt and the file in the home directory both came across. Nothing was reinstalled. ### 6. List and delete the template ```bash sbx snapshots ``` ```text Named snapshots NAME STATUS SOURCE TYPE SIZE CREATED aeo-tpl-figlet ready sb_cauu1dfagw small 20.1 GB just now Capture history … ``` ```bash sbx snapshot rm aeo-tpl-figlet ``` ```text Removed named snapshot aeo-tpl-figlet ``` After that, `sbx new --from aeo-tpl-figlet` fails with `No named snapshot "aeo-tpl-figlet". (snapshot_not_found)`. ## Rules and limits - **Names** are 1 to 63 lowercase letters, digits or dashes. `latest`, `tree`, `pull`, `rm`, `save`, `current`, `self` and `new` are reserved. We tried `latest` and got `invalid_name` (HTTP 400). - **No versions.** Saving again under the same name replaces the template. To keep old ones, put a version in the name, for example `web-stack-v2`. - **Size.** New sandboxes default to the template's size. Pass `--type` to override it. - **What's saved:** everything on the disk, including `/home/user`, `/etc`, installed packages, enabled systemd services, and Docker images and volumes. Running processes are not saved: a sandbox from a template boots fresh, and enabled systemd services start. - **Storage.** Named snapshots have no separate charge. What they store counts toward your plan's included storage, together with stopped sandboxes. Storage is measured as the data beyond the shared OS image, about every 10 minutes. | Plan | Storage included | | - | - | | No plan | 10 GB | | Time packs without a plan | 50 GB | | $20 / month | 50 GB | | $100 / month | 250 GB | | $500 / month | 1 TB | | $2,000 / month | 5 TB | When storage is full, the API returns `402 storage_limit_reached`. Delete stopped sandboxes or named snapshots, or upgrade. ## Environment, setup script or named snapshot? Sandboxer has three ways to decide what a new sandbox starts with. They solve different problems, and you can combine them. | | Environment | Setup script | Named snapshot | | - | - | - | - | | Holds | Variables, secret files, GitHub repos, credential switches | A shell script, up to 64 KB | The whole disk | | Applied | At create; updates reach running sandboxes on upgrade | Runs in the background as `user` after `ready` | At create, before `ready` | | Repeats work on every create | Clones repos | Yes, the script runs each time | No, installs are already done | | Versions | Every change is an immutable version; sandboxes stay pinned until `sbx env upgrade` | Whatever file you pass | None; a re-save replaces it | | Best for | Secrets and per-project config | Small per-sandbox steps | Slow installs: packages, toolchains, Docker images, datasets | | Flag | `--environment ` | `--setup-file ./setup.sh` | `--from ` | A common pattern is all three at once. Keep heavy installs in the template, secrets in an environment, and per-sandbox steps in a short script: ```bash sbx new --from web-stack --environment prod --setup-file ./setup.sh ``` The script's progress shows in the sandbox's `setupStatus` field (`pending`, `running`, `done`, `failed`). ## Share a template with another account ```bash sbx snapshot share web-stack # prints a bsh_… code once sbx snapshot shares web-stack # list codes and how often they were used sbx snapshot unshare web-stack shr_… # revoke one ``` The other account runs `sbx new --from bsh_…`. The sandbox is theirs and bills them. Your logins, API keys, secret files, repository credentials and SSH keys are removed from it. We did not run sharing for this guide; the commands are from the [snapshot docs](/docs/snapshots). ## FAQ ### Does a template keep running processes? No. A named snapshot saves the disk, so a sandbox started from it boots fresh. Enabled systemd services start on boot. To keep processes running in the same sandbox, stop and resume it instead. ### Can I delete the sandbox I saved the template from? Yes. A named snapshot is independent of its source. We deleted the source and then started a new sandbox from the template. ### How do I update a template? Start a sandbox from it, make your changes, and save it again under the same name. That replaces the old one. Use a new name if you want to keep the previous version. ### Does a template cost anything? Named snapshots have no separate charge. What they store counts toward your plan's included storage, which starts at 50 GB on the $20 plan. ### Should secrets go in a template? Keep them in an environment instead. Environments are versioned, can be upgraded on running sandboxes, and `--no-env` keeps them out of sandboxes you give to other people. ## Related - [Snapshots](/docs/snapshots) - [Environments and secrets](/docs/environments) - [Billing and storage](/docs/billing) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # SSH Into a Cloud Sandbox From VS Code or Cursor Source: https://sandboxer.com/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor. Updated 2026-10-27. **Short answer:** Run sbx ssh-config --install once. It adds a Host *.sbx entry to ~/.ssh/config that tunnels through the sbx CLI over HTTPS, so ssh, rsync and VS Code or Cursor Remote-SSH reach any sandbox as .sbx, with no open ports and no keys to manage by hand. ## Three ways in Every sandbox runs a standard OpenSSH server as `user`. You can reach it three ways: | Route | How you connect | Needs | Best for | | - | - | - | - | | Tunnel via `sbx ssh-config` | `ssh sb_xxx.sbx` | Outbound HTTPS only | Editors, `rsync`, `scp`, anything that reads `~/.ssh/config` | | IPv4 `sshEndpoint` | `ssh -p user@` | TCP to that port | SSH clients that can't run a `ProxyCommand` | | Direct IPv6 | `ssh user@` | IPv6 on your network | Networks with IPv6 | The tunnel is the one to use for an editor. It works behind corporate firewalls, it uses the sandbox id as the host name, and the CLI authorizes its key and pins the host key on every connection. ## 1. Get a sandbox and try sbx ssh ```bash sbx new --name aeo-ssh-1 --type small sbx ssh sb_sohclk8pmb "uname -a; whoami; pwd" ``` ```text Linux solid-zesty-koala 6.18.56-sandboxer #2 SMP PREEMPT_DYNAMIC Tue Oct 13 02:50:06 CEST 2026 x86_64 x86_64 x86_64 GNU/Linux user /home/user ``` `sbx ssh` keeps a dedicated key at `~/.ssh/sandboxer_ed25519` and authorizes it on the sandbox the first time. The remote exit code is passed through: `sbx ssh sb_sohclk8pmb "exit 7"` exits with 7. ## 2. Add the SSH config entry ```bash sbx ssh-config --install ``` ```text Added the Sandboxer stanza to /home/you/.ssh/config. Try: ssh .sbx ``` Running it again changes nothing (`… already has the Sandboxer stanza.`). This is the block it writes. Paths are absolute on your machine. Here the home directory is shortened to `/home/you` and the `sbx` path to `/usr/local/bin/sbx`: ```ini # Sandboxer (sbx ssh-config) Host *.sbx User user IdentityFile /home/you/.ssh/sandboxer_ed25519 IdentitiesOnly yes ProxyCommand /usr/local/bin/sbx proxy %h UserKnownHostsFile /home/you/.config/sandboxer/known_hosts StrictHostKeyChecking yes ServerAliveInterval 30 ``` `sbx proxy` bridges the SSH connection to port 22 of the sandbox over an authenticated WebSocket. Before it connects, it authorizes the CLI key and pins the sandbox's current host key, so `StrictHostKeyChecking yes` holds without any prompt. To see the block without writing it, run `sbx ssh-config` with no flag. ## 3. Use plain ssh, rsync and scp Any OpenSSH tool now reaches the sandbox as `.sbx`: ```bash ssh sb_sohclk8pmb.sbx "uname -sr; hostname; nproc" ``` ```text Linux 6.18.56-sandboxer solid-zesty-koala 2 ``` That round trip took 1.5 seconds, including the authorize step (one measured run on prod, 2026-10-27). Sync a local folder and read a file back: ```bash rsync -av ./app/ sb_sohclk8pmb.sbx:app/ ssh sb_sohclk8pmb.sbx "node app/index.js" scp sb_sohclk8pmb.sbx:app/README.md ./back.md ``` ```text sending incremental file list created directory app ./ README.md index.js sent 224 bytes received 83 bytes 122.80 bytes/sec total size is 37 speedup is 0.12 hi from laptop ``` ## 4. Open it in VS Code or Cursor In VS Code, install the Remote - SSH extension, open the Command Palette and choose **Remote-SSH: Connect to Host…**, then type `sb_sohclk8pmb.sbx`. Remote-SSH reads hosts from your SSH config file, so the `Host *.sbx` entry above is all it needs. If you keep that entry in a different file, point the `remote.SSH.configFile` setting at it ([VS Code Remote-SSH docs](https://code.visualstudio.com/docs/remote/ssh)). Open `/home/user` or your repo folder once it connects. Cursor imports VS Code settings and extensions and installs extensions from the Open VSX registry ([Cursor: migrate from VS Code](https://cursor.com/help/getting-started/migrate-vscode.md)). Install a Remote-SSH extension there and connect to the same `sb_xxx.sbx` host. We couldn't open a GUI editor for this guide. What we checked is the part the editor depends on: the SSH connection through that exact config block, and that the sandbox is the kind of host VS Code's server supports (glibc-based x86_64 Linux, glibc 2.17 or newer) with `tar`, `curl` and `wget` present: ```bash ssh sb_sohclk8pmb.sbx "ldd --version | head -1; which tar curl wget" ``` ```text ldd (Ubuntu GLIBC 2.39-0ubuntu8.9) 2.39 /usr/bin/tar /usr/bin/curl /usr/bin/wget ``` ## 5. Reach a dev server from your laptop Start a server in the sandbox, then forward its port. Either use the CLI: ```bash sbx exec sb_sohclk8pmb --detach "mkdir -p site && cd site && echo hello-from-port-3000 > index.html && python3 -m http.server 3000" sbx forward sb_sohclk8pmb --remote 3000 --local 13000 curl http://127.0.0.1:13000/ ``` ```text hello-from-port-3000 ``` Or plain SSH local forwarding through the same config entry, which is what an editor's port forwarding uses: ```bash ssh -N -L 13001:127.0.0.1:3000 sb_sohclk8pmb.sbx curl http://127.0.0.1:13001/ ``` ```text hello-from-port-3000 ``` Nothing becomes public either way. For a shareable HTTPS URL, use `sbx host` ([Hosting](/docs/hosting)). ## Without the CLI: the IPv4 endpoint `sbx info` shows the sandbox's public IPv4 `sshEndpoint`, a `host:port` forwarded to port 22 while it runs: ```text ID: sb_sohclk8pmb State: ready Type: small (2 vCPU, 4 GB) IP: 2a01:4f9:3120:1d84::5b00:10 SSH endpoint: 62.238.91.250:22015 … ``` ```bash ssh -i ~/.ssh/sandboxer_ed25519 -p 22015 user@62.238.91.250 "hostname" ``` ```text solid-zesty-koala ``` The key must be authorized on that sandbox first. `sbx ssh` does it for the CLI key, or you can add your own public key through the API ([SSH docs](/docs/ssh)). An editor config for this route uses `HostName`, `Port` and `IdentityFile` instead of `ProxyCommand`. Prefer the `.sbx` tunnel when you can: the endpoint port belongs to the running sandbox, while the `.sbx` host name is just the id. Direct IPv6 (`ssh user@`) needs IPv6 on your side. Our test machine had none and got `Network is unreachable`, which is the error to expect in that case. The tunnel and the IPv4 endpoint both worked from the same machine. ## FAQ ### Do I need to open any ports or copy keys? No. The `.sbx` entry tunnels over outbound HTTPS through the CLI, and each connection authorizes the CLI key and pins the host key for you. ### Does the editor connection survive a stop and resume? The host name is the sandbox id, which doesn't change, and `sbx proxy` pins the sandbox's current host key on each connection. After `sbx resume`, reconnect to the same `sb_xxx.sbx` host. `sbx forward` reconnects on its own if the sandbox moves. ### Why does my editor say "Could not establish connection"? Check plain `ssh sb_xxx.sbx true` in a terminal first. If the sandbox is stopped, resume it. If `sbx` isn't signed in on that computer, run `sbx login`, because the `ProxyCommand` uses the CLI's saved sign-in. ### Can I use JetBrains Gateway or Ansible the same way? Any tool that uses OpenSSH and reads `~/.ssh/config` can use the `Host *.sbx` entry. We tested `ssh`, `scp` and `rsync` for this guide. ### What user and home directory will I land in? `user`, in `/home/user`, with root available through `sudo`. ## Related - [SSH and file transfer](/docs/ssh) - [CLI reference](/docs/cli) - [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # How Do I Give a Port in a Cloud Sandbox an HTTPS URL? Source: https://sandboxer.com/guides/give-a-sandbox-port-an-https-url. Updated 2026-10-27. **Short answer:** Start your server on a port, then run sbx host . You get https://-.box.sandboxer.com with TLS and WebSockets. It is private by default: requests without the URL's _token get 401. Add --public for an open URL, or --localhost for apps bound to 127.0.0.1. ## At a glance | You want | Run | What a request without the token gets | | - | - | - | | A link only you and the people you send it to can open | `sbx host $id 8000` | `401 Access token required` | | A URL anyone can open | `sbx host $id 8000 --public` | `200` | | A dev server that listens on 127.0.0.1 only | `sbx host $id 5173 --localhost` | Depends on `--public` | | To see what's hosted | `sbx host list $id` | | | To take a URL down | `sbx host hide $id 8000` | `404 Port not hosted` | Everything below was run on a `small` sandbox on 2026-10-27. Tokens are replaced with ``. ## Host a port Start a server on a known port, bound to `0.0.0.0`, then host it. ```bash id=sb_m0lu5n3z2r # your sandbox id from `sbx new` sbx exec $id --detach "mkdir -p site && cd site && echo hello from the sandbox > index.html && python3 -m http.server 8000" sbx host $id 8000 --title "Demo" ``` ```text https://frank-ready-marsh-8000.box.sandboxer.com?_token= Private URL: keep the _token secret. `sbx host sb_m0lu5n3z2r 8000 --public` makes it ungated. ``` The hostname is the sandbox's subdomain plus the port. Running `sbx host` again for the same port returned the same URL and the same token. Inside the sandbox, the shorter `host 8000` does the same thing for the sandbox it runs in. ## What a private URL does without its token Without the token, the proxy answers itself. Your server never sees the request. ```bash curl -si https://frank-ready-marsh-8000.box.sandboxer.com/ | head -1 ``` ```text HTTP/2 401 ``` The body is a page titled "Access token required". A wrong token also gets `401`. With the token, the first response is a redirect that sets a cookie and drops the token from the address bar: ```bash curl -si "https://frank-ready-marsh-8000.box.sandboxer.com?_token=" ``` ```text HTTP/2 302 location: / set-cookie: __sbx_auth=; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax ``` After that, the cookie alone is enough, so a browser can follow links and load assets normally. The cookie lasts 30 days. Treat the full URL and the cookie like a password. ## Make it public, or private again ```bash sbx host $id 8000 --public curl -s -w "\n%{http_code}\n" https://frank-ready-marsh-8000.box.sandboxer.com/ ``` ```text hello from the sandbox 200 ``` `sbx host $id 8000 --private` gates the URL again with a new token. Switching between public and private mints a new token, so links and cookies from before stop working: in our re-test on 2026-10-27, the old private link answered `401` after a public-then-private switch, and the new one worked. (An earlier run that night, before a fix shipped, kept the same token.) Running `sbx host` again with the same access keeps the token, so to cut someone off, switch the port to public and back to private, or stop serving it with `sbx host hide`. ## Apps that listen on localhost only Many dev servers (Vite, for example) bind to `127.0.0.1`. The proxy connects to the sandbox's own address, so a normal `host` can't reach them: ```bash sbx exec $id --detach "mkdir -p lh && cd lh && echo only on loopback > index.html && python3 -m http.server 5173 --bind 127.0.0.1" sbx host $id 5173 --public curl -s -o /dev/null -w "%{http_code}\n" https://frank-ready-marsh-5173.box.sandboxer.com/ ``` ```text 502 ``` The `502` page says "Nothing is answering on port 5173". Host it with `--localhost` and the sandbox relays the connection to the loopback address: ```bash sbx host $id 5173 --public --localhost curl -s https://frank-ready-marsh-5173.box.sandboxer.com/ ``` ```text only on loopback ``` Each `host` call sets the mode again, so hosting without `--localhost` turns the relay off. Don't use `--localhost` for an app that binds `0.0.0.0`. Some dev servers also reject unknown `Host` headers: for Vite, add `.box.sandboxer.com` to `server.allowedHosts`. ## WebSockets We ran a small WebSocket echo server on port 8765 and connected with Node 24's built-in `WebSocket`. On a public port it works as is: ```text wss://frank-ready-marsh-8765.box.sandboxer.com/ -> echo: hello ``` On a private port, how you pass the token matters: | Handshake carries | Result | | - | - | | Nothing | `401` | | `?_token=` | `302` redirect, which WebSocket clients treat as a failed handshake | | The `__sbx_auth` cookie | `101 Switching Protocols`, works | In a browser this works without extra steps: opening the token link sets the cookie, and the page's own WebSocket connections send it. A script has to trade the token for the cookie first: ```javascript // node ws-cookie.mjs "" const url = new URL(process.argv[2]); const res = await fetch(url, { redirect: "manual" }); const cookie = res.headers.get("set-cookie").split(";")[0]; url.searchParams.delete("_token"); url.protocol = "wss:"; const ws = new WebSocket(url, { headers: { Cookie: cookie } }); ws.onopen = () => ws.send("hello"); ws.onmessage = (e) => { console.log(e.data); process.exit(0); }; ``` ```text echo: hello ``` ## List and take down ```bash sbx host list $id ``` ```text PORT ACCESS URL TITLE 5173 public (localhost) https://frank-ready-marsh-5173.box.sandboxer.com 8000 private https://frank-ready-marsh-8000.box.sandboxer.com?_token=%E2%80%A6 Demo ``` The list hides tokens. `sbx host hide $id 5173` removes the route and turns off the relay. The URL then returns `404` ("Port not hosted"), but the server keeps running inside the sandbox. A port you never hosted returns the same `404`. Only hosted ports are reachable this way. ## After stop, resume and fork - **Stopped:** while the sandbox was stopped, its URLs returned `404` ("No sandbox uses this address"). - **Resumed:** `sbx resume` brought back every route and token. In our test the resume restored memory, so the same `http.server` processes (same PIDs) answered at once: the public URL returned `200`, the old private link returned its `302`, and a 30-day cookie from before the stop still worked. Memory is restored only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk and shows `memoryRestored: false`: routes and tokens are kept, but you must start your servers again (or run them as enabled systemd services). - **Forked:** `sbx fork` gave a new subdomain (`woody-merry-quarry`), no hosted ports and no running servers. Start your server and host it again on the fork. ## Sandboxer hosting or a tunnel? Tunnels like ngrok and Cloudflare Tunnel solve a related problem: they publish a port on a machine you already run, through an agent process that connects out. The table uses only what each vendor's own docs say. | | `sbx host` | ngrok | Cloudflare quick tunnel | Cloudflare named tunnel | | - | - | - | - | - | | Command | `sbx host ` | `ngrok http 8080` ([quickstart](https://ngrok.com/docs/share-localhost/quickstart)) | `cloudflared tunnel --url http://localhost:8080` ([docs](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)) | Configured in the dashboard or API ([docs](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/)) | | Extra process to run | None | The ngrok agent | `cloudflared` | `cloudflared` | | Account | Your Sandboxer account | An ngrok account and authtoken | Not needed | Cloudflare account, with a website added to Cloudflare | | Hostname | `-.box.sandboxer.com`, the same across stop and resume | A dev domain chosen automatically for your account | A random `trycloudflare.com` name that changes on each start | Your own hostname | | Access control | Private token by default, or `--public` | OAuth, basic auth and more through Traffic Policy ([auth](https://ngrok.com/docs/share-localhost/auth)) | None listed | Open to anyone unless you add a Cloudflare Access application | | Stated limits | Only hosted ports are reachable | | 200 in-flight requests, no Server-Sent Events, no uptime guarantee, for testing | | Use `sbx host` when the server already runs in a Sandboxer sandbox. Use a tunnel when the server runs somewhere else, such as your laptop, or when you need your own domain today. Sandboxer doesn't offer first-class custom domains yet: you can point a CNAME at the hosted name and terminate TLS yourself, as described in [Hosting](/docs/hosting). ## FAQ ### Is the URL public by default? No. A new hosted port is private. Requests without the URL's `_token` (or the cookie it sets) get `401`, and they never reach your server. Add `--public` for an open URL. ### Why do I get 502 "Nothing is answering"? Either the server isn't running, or it listens on `127.0.0.1` only. Bind it to `0.0.0.0`, or host the port with `--localhost`. ### Do WebSockets and hot module reload work? Yes. On a public port they work directly. On a private port the handshake needs the `__sbx_auth` cookie, which a browser already has after opening the token link. ### Does the URL change when the sandbox stops and resumes? No. Routes and tokens are kept, and the subdomain stays the same. A fork gets a new subdomain with no hosted ports. ### Do I need to open the firewall for a hosted port? No. Hosted traffic comes through the HTTPS proxy. You only need `sudo ufw allow ` to serve directly on the sandbox's IPv6 address. ## Related - [Hosting](/docs/hosting) - [Long-running tasks](/docs/long-running) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Security](/docs/security) --- # How to Run a Long Job in a Cloud Sandbox and Check on It Later Source: https://sandboxer.com/guides/run-a-long-job-in-a-cloud-sandbox. Updated 2026-10-27. **Short answer:** Start the job with sbx exec --detach, which returns a process id at once, then close your terminal. Later, sbx exec --status shows whether it's still running, its exit code and the end of its output. Extend auto-stop past the job's length, because a running job keeps the sandbox awake but doesn't hold off auto-stop. ## At a glance | What you want | Command | What we saw | | - | - | - | | Start a job and walk away | `sbx exec $id --detach ""` | Returns a `processId` and `commandId` at once | | Is it done? What was the exit code? | `sbx exec $id --status $pid` | `running`, then `exited (exit 0)` with start and finish times | | Watch it live | `sbx exec $id --attach $pid` | Prints the log from the start, then exits with the job's exit code | | Read the output after it ends | `sbx history $id ` | The full stored stdout | | Stop it | `sbx exec $id --kill $pid` | Exit code `143` (`SIGTERM`), listed as `killed` | | Keep the deadline ahead of the job | `sbx extend $id --hours 2` | Auto-stop moves to now + 2 hours | ## Pick the right way to run it | Job | Use | Why | | - | - | - | | Under 10 minutes, you wait for it | `sbx exec $id --timeout 600 ""` | Synchronous commands stop at 600 seconds | | Minutes to hours, you check later | `sbx exec --detach` | Survives your disconnect, and a stop and resume | | Must run forever, survive a cold boot or a fork | A systemd service | Detached processes don't come back after a cold boot | | A daemon whose work the API can't see | Any of the above plus `~/.sandboxer/keep-awake` | Stops idle pause from treating it as idle | ## 1. Start the job detached We ran a 150-second job (30 steps, 5 seconds apart) on a `small` sandbox ($0.018/hour) on prod on 2026-10-27. Swap in your own build, test suite or training script. ```bash id=$(sbx new --type small --json | jq -r 'select(.event == "ready") | .id') sbx exec $id --detach 'for i in $(seq 1 30); do echo "step $i/30 $(date -u +%T)"; sleep 5; done; echo "job finished"' ``` ```json {"ok":true,"type":"command.started","commandId":"cmd_kzz60tfkzjkw","pid":523,"processId":523, "logPath":"/home/user/.sandboxer/processes/523.log","errLogPath":"/home/user/.sandboxer/processes/523.err.log", "startedAt":"2026-10-27T02:02:19.91067523Z","success":true, …} ``` Keep two values: `processId` (for `--status`, `--attach` and `--kill`) and `commandId` (for `sbx history`). In a script, capture the pid directly: ```bash pid=$(sbx exec $id --detach "npm run build" | jq -r .processId) ``` The CLI returns at once. Close the terminal, shut the laptop, or let your CI job end. The job runs inside the VM, not on your machine. ## 2. Check on it later ```bash sbx exec $id --status 523 ``` ```text Process: 523 Status: running Command: for i in $(seq 1 30); do echo "step $i/30 $(date -u +%T)"; sleep 5; done; echo "job finished" Started: 2026-10-27T02:02:19.91067523Z Log: /home/user/.sandboxer/processes/523.log --- stdout (tail) --- step 1/30 02:02:19 ``` We came back three minutes later: ```text Process: 523 Status: exited (exit 0) Started: 2026-10-27T02:02:19.91067523Z Finished: 2026-10-27T02:04:49.977383159Z Log: /home/user/.sandboxer/processes/523.log --- stdout (tail) --- step 1/30 02:02:19 … step 30/30 02:04:44 job finished ``` In a script, wait for the job and read its exit code from the JSON: ```bash while [ "$(sbx exec $id --status $pid --json | jq -r .running)" = true ]; do sleep 30; done sbx exec $id --status $pid --json | jq '{status, exitCode, startedAt, finishedAt}' ``` ```json { "status": "exited", "exitCode": 3, "startedAt": "2026-10-27T02:13:39.178228158Z", "finishedAt": "2026-10-27T02:16:56.259617937Z" } ``` That second job ended with `exit 3` on purpose. `sbx exec --attach` also passes the exit code through: it printed the log from step 1 to `job finished` and then exited with status `3`, so a CI step that attaches fails when the job fails. ## 3. Read the output after the job ends Every command run through the API is recorded. `sbx history` lists them, newest first. It also works while the sandbox is stopped. ```bash sbx history $id ``` ```text COMMAND-ID STATUS MODE STARTED COMMAND cmd_11evxgkfczn9 exit 3 detached 2026-10-27 02:13:39 for i in $(seq 1 30); do echo "step $i/30 $(date -u +%T)"; sleep 5; d… cmd_kzz60tfkzjkw exit 0 detached 2026-10-27 02:02:19 for i in $(seq 1 30); do echo "step $i/30 $(date -u +%T)"; sleep 5; d… … ``` ```bash sbx history $id cmd_11evxgkfczn9 | tail -3 ``` ```text step 29/30 02:16:46 step 30/30 02:16:51 job finished ``` Add `--follow` to keep printing new output while the job runs, or `--stderr` for its error stream. Commands run through `exec` are kept for 90 days. ## 4. Keep the sandbox from idle-pausing A sandbox pauses itself after `idleStopMinutes` without activity (by default 30 to 120 minutes, depending on the plan). A detached command that is still running counts as activity. To check, we set the idle limit to 1 minute before the 150-second job: ```bash sbx idle $id 1 ``` ```text sb_ahaky0zrmj idle pause: after 1 idle minutes (wakes on use) ``` The job logged a step every 5 seconds with no gap, so the sandbox never paused. Once the job ended and nothing else touched the sandbox, it paused about 70 seconds after the last activity, and `sbx info` showed `"stop": {"reason": "idle"}`. The next `sbx exec` woke it and returned in 0.6 seconds (one measured run). A sandbox also stays awake while the 1-minute load average is at least 0.5, or while `~/.sandboxer/keep-awake` exists. Use the file for work the API can't see, such as a server waiting on a queue: ```bash sbx exec $id "mkdir -p ~/.sandboxer && touch ~/.sandboxer/keep-awake" # later, to allow pausing again: sbx exec $id "rm ~/.sandboxer/keep-awake" ``` With the file in place and the idle limit still at 1 minute, the sandbox stayed `ready` for the three minutes we waited. To turn idle pause off for one sandbox, run `sbx idle $id 0`. ## 5. Make auto-stop outlast the job Auto-stop is a separate, hard deadline: one hour after start by default. **A running job doesn't hold it off.** We started the job again and cut the deadline to 60 seconds: ```bash sbx extend $id --ttl 60 ``` ```text sb_ahaky0zrmj auto-stop: 2026-10-27 02:14 (in 1m00s) ``` At 02:14:42 the sandbox stopped with `"reason": "auto_stop"`, mid-job. The stop saved memory, and the resume below restored it, so nothing was lost. The sandbox doesn't wake by itself after an auto-stop: ```bash sbx exec $id --status 614 ``` ```text error: The sandbox is stopped. Resume it first. (machine_not_running) [HTTP 409, req_5s6vTnD07PeYxIGUGHX6] ``` ```bash sbx resume $id sbx exec $id --status 614 ``` ```text Status: running … step 13/30 02:14:39 step 14/30 02:15:31 ``` The job picked up where it froze: step 13, a 52-second gap while the sandbox was stopped, then step 14. It finished normally after that. Memory comes back only when the resume lands on a machine with the same CPU type. If it doesn't, the sandbox boots from disk (`memoryRestored: false`), the job's status becomes `lost` and you rerun it; `sbx resume --require-memory` waits for a matching machine instead. Resuming starts the countdown again from the sandbox's TTL. Ours was still 60 seconds, so it would have stopped again within two minutes. Extend right after you resume: ```bash sbx extend $id --hours 1 # new deadline: now + 1 hour sbx extend $id --no-auto-stop # or no deadline at all ``` For a job you know is long, set the TTL when you create the sandbox (`sbx new --ttl 28800` for 8 hours). ## 6. Stop a job early ```bash sbx exec $id --kill $pid ``` ```text Stopped process 528 and its children ``` `--status` then shows `exitCode` `143` and `signal` `SIGTERM`, and `sbx history` lists the command as `killed 143`. `sbx ps $id` lists live processes with their history id, so you can find a job you started without saving its pid. ## FAQ ### Does a detached job survive if I close my terminal? Yes. The job runs inside the VM, and `sbx exec --detach` returns as soon as it starts. Check it later from any machine with your API key. ### Does a running job stop the sandbox from auto-stopping? No. It stops idle pause, not auto-stop. Set `--ttl` on `sbx new` or run `sbx extend` so the deadline lands after the job ends. ### What happens to my job if the sandbox stops? When the resume restores memory, the job continues from where it froze. In our test it went from step 13 to step 14 with no restart. If the resume cold-boots instead (for example on a different CPU type, shown as `memoryRestored: false`), the job's status becomes `lost`, and its log files stay on disk. ### Where does the job's output go? To `~/.sandboxer/processes/.log` and `.err.log` in the sandbox. It's also kept in command history for 90 days, which you can read with `sbx history` even while the sandbox is stopped. ### How long can a single job run? The 600-second limit applies only to synchronous commands; for anything longer, detach. The sandbox's auto-stop is what bounds the job, and it can be set up to 30 days or turned off. ## Related - [Long-running tasks](/docs/long-running) - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [CLI reference](/docs/cli) - [Limits and errors](/docs/limits) --- # Run Docker Compose in a Sandbox With a URL per Service Source: https://sandboxer.com/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service. Updated 2026-10-27. **Short answer:** Copy your compose.yaml into a Sandboxer sandbox, run docker compose up -d (Docker and Compose are preinstalled), then run sbx host for each published port. Each service gets its own HTTPS URL. A memory-restoring stop and resume keeps containers running; add restart unless-stopped so they also come back after a cold boot. ## At a glance | Step | Command | What we saw (single runs, October 27, 2026) | | - | - | - | | Check versions | `sbx exec $id "docker compose version"` | Docker 29.9.0, Docker Compose v5.6.0, `docker.service` enabled | | Start the stack | `sbx exec $id "cd app && docker compose up -d"` | 3 images pulled and 3 containers started in about 4 seconds | | One URL per service | `sbx host $id 3000 --public`, `sbx host $id 8000` | `https://-3000…` public, `https://-8000…?_token=…` private | | Internal services | No `ports:`, no `host` | Redis reachable from the API container only | | Stop and resume | `sbx stop $id --wait`, `sbx resume $id` | Same container PIDs and start times, counter kept counting | | Cold boot (resize) | `sbx resume $id --type large` | Containers with a restart policy came back; one without it stayed `Exited (255)` | ## The stack A web front end (nginx), an API (Python, standard library only) and Redis. The API counts hits in Redis. Only `web` and `api` publish ports; Redis stays on the Compose network. ```yaml # app/compose.yaml services: web: image: nginx:1.27-alpine ports: - "3000:80" volumes: - ./web:/usr/share/nginx/html:ro restart: unless-stopped api: image: python:3.12-alpine command: python /app/api.py environment: REDIS_HOST: redis ports: - "8000:8000" volumes: - ./api.py:/app/api.py:ro depends_on: - redis restart: unless-stopped redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis-data:/data restart: unless-stopped volumes: redis-data: ``` ```python # app/api.py import json, os, socket from http.server import BaseHTTPRequestHandler, HTTPServer def incr(key): # One Redis INCR over a raw socket, so the API needs no packages. with socket.create_connection((os.environ["REDIS_HOST"], 6379)) as s: s.sendall(f"*2\r\n$4\r\nINCR\r\n${len(key)}\r\n{key}\r\n".encode()) return int(s.recv(64).decode().strip().lstrip(":")) class Handler(BaseHTTPRequestHandler): def do_GET(self): body = json.dumps({"service": "api", "hits": incr("hits")}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(body) HTTPServer(("0.0.0.0", 8000), Handler).serve_forever() ``` `app/web/index.html` holds one line of HTML. ## Run it ```bash sbx new --name compose-demo id=sb_glb31hca2m # the id that sbx new printed sbx scp -r ./app $id:/home/user/ sbx exec $id "cd app && docker compose up -d" sbx exec $id "cd app && docker compose ps" ``` ```text NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS app-api-1 python:3.12-alpine "python /app/api.py" api Less than a second ago Up Less than a second 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp app-redis-1 redis:7-alpine "docker-entrypoint.s…" redis 1 second ago Up Less than a second 6379/tcp app-web-1 nginx:1.27-alpine "/docker-entrypoint.…" web 1 second ago Up Less than a second 0.0.0.0:3000->80/tcp, [::]:3000->80/tcp ``` Publish ports on `0.0.0.0` (the Compose default for `"3000:80"`). The hosting proxy connects to the sandbox's own address, so a port published only on `127.0.0.1` needs `sbx host … --localhost`. ## Give each service a URL ```bash sbx host $id 3000 --public --title web # https://mellow-modern-pine-3000.box.sandboxer.com sbx host $id 8000 --title api # https://mellow-modern-pine-8000.box.sandboxer.com?_token= # Private URL: keep the _token secret. `sbx host sb_glb31hca2m 8000 --public` makes it ungated. sbx host list $id ``` ```text PORT ACCESS URL TITLE 3000 public https://mellow-modern-pine-3000.box.sandboxer.com web 8000 private https://mellow-modern-pine-8000.box.sandboxer.com?_token= api ``` The URL pattern is `https://-.box.sandboxer.com`, so each published port is its own hostname with TLS. What `curl` returned: ```bash curl -i https://mellow-modern-pine-3000.box.sandboxer.com/ # HTTP/2 200 #

web: served by nginx inside a Sandboxer sandbox

curl -o /dev/null -w '%{http_code}\n' https://mellow-modern-pine-8000.box.sandboxer.com/ # 401 (no token) curl -L -c jar.txt -b jar.txt "https://mellow-modern-pine-8000.box.sandboxer.com/?_token=" # {"service": "api", "hits": 1} ``` The first request with the token sets a cookie, so later requests from the same browser don't need it. Redis has no hosted port, so nothing outside the sandbox can reach it. Keep databases that way and let your services talk over the Compose network. ## Stop and resume: containers keep running We started one extra container without a restart policy (`docker run -d --name no-policy nginx:1.27-alpine`), noted every container's PID and start time, then stopped and resumed. ```bash sbx stop $id --wait # resume is refused with sandbox_stopping until the snapshot is done sbx resume $id sbx exec $id "docker inspect -f '{{.Name}} pid={{.State.Pid}} started={{.State.StartedAt}}' \$(docker ps -q)" ``` ```text /no-policy pid=1045 started=2026-10-27T02:26:48.205625423Z /app-api-1 pid=921 started=2026-10-27T02:26:30.404893038Z /app-web-1 pid=735 started=2026-10-27T02:26:30.118245588Z /app-redis-1 pid=852 started=2026-10-27T02:26:30.267902655Z ``` Same PIDs and start times as before the stop, including the container with no restart policy: this resume restored memory. That happens only on a machine with the same CPU type; if none is free within 2 minutes, the resume cold-boots as in the next section. The API answered `{"hits": 3}`, continuing from 2: Redis kept its in-memory data. While the sandbox was stopped, the URLs returned an error page instead of your app (`404` in one run, `503` "Sandbox is not running" in another). After resume they answered again with no `sbx host` call; routes and tokens are kept. ## Cold boot: only containers with a restart policy come back Resume boots from disk instead of restoring memory when you change size, when the memory image can't be used, or when no machine with the same CPU type is free (see [Snapshots](/docs/snapshots)). To see what Compose does then, we stopped and resumed with `--type large`, which always cold-boots: ```bash sbx stop $id --wait sbx resume $id --type large sbx exec $id "cat /proc/uptime; docker ps -a --format 'table {{.Names}}\t{{.Status}}'" ``` ```text 1.95 11.17 NAMES STATUS no-policy Exited (255) 1 second ago app-api-1 Up Less than a second app-web-1 Up Less than a second app-redis-1 Up Less than a second ``` An uptime of 2 seconds confirms a fresh boot. Docker is an enabled systemd service, so it started, and it restarted the three Compose containers because of `restart: unless-stopped`. The container without a policy stayed down. Both URLs worked without re-hosting. One thing did not survive: the hit counter. That first run used Redis without `--appendonly yes`, and the API answered `{"hits": 1}`. Redis held the count in memory and had not written it to disk, and a cold boot doesn't restore memory. With `--appendonly yes` (the file above), we killed Redis with `docker kill -s KILL app-redis-1` and started it with `docker compose start redis` (Docker treats a kill as a manual stop and doesn't restart it): the log said `DB loaded from append only file` and the count continued from 4 to 5. Turn on your database's durability settings if a cold boot must not lose writes. See [Redis persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) and the [Compose `restart` attribute](https://docs.docker.com/reference/compose-file/services/#restart). ## How to tell which resume you got Check the processes rather than guessing: - `cat /proc/uptime` small after resume: cold boot. Large and still counting from before the stop: memory was restored. - `docker inspect -f '{{.State.StartedAt}}'` unchanged: the container never restarted. - `sbx info` reports `memoryRestored: true` after a memory resume and `false` after a boot from disk, with `resumeReason` (`cpu_mismatch`, `resized`, `no_memory_snapshot` or `restore_failed`). In our runs on 2026-10-27, before that fix shipped later the same night, the field read `null` both ways; the uptime and container checks above work either way. To refuse a cold boot, use `sbx resume $id --require-memory`. It waits up to 30 minutes for a machine that can restore memory, and fails instead of booting from disk. Ours resumed with memory: `/proc/uptime` read 16.6 seconds, counting on from before the stop. ## FAQ ### Which size should I use for a Compose stack? `default` (4 vCPU, 8 GB, 50 GB disk) ran this three-container stack with room to spare. Use `large` for heavy builds or many containers. Growing on resume works. Moving to a smaller size isn't supported yet (our resumes from `large` back to `default`, and from `default` to `small`, were refused with `type_too_small`), so pick the size you plan to stay on. To get a smaller machine, create a new sandbox at that size, copy your project and `compose.yaml` across (for example with `sbx scp -r` or a `git clone`), run `docker compose up -d` there and host its ports again. ### Do I need privileged containers or the host's Docker socket? No. Each sandbox is its own VM with its own Docker daemon. `user` is in the `docker` group, so `docker compose` runs without `sudo`. See [Docker and KVM in a sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox). ### Can I keep some service URLs private? Yes. `sbx host` is private by default: the URL carries a `_token` and returns `401` without it. Add `--public` per port to open it to anyone. Use `--public` only for services you're happy for anyone to reach. ### Do I have to re-host ports after a stop? No. Hosted routes and tokens are kept across stop and resume, and the subdomain never changes. A fork gets a new subdomain and no routes, so host its ports again. ### Does the sandbox stop on its own? Yes, after one hour by default, and after a period of inactivity. Both are memory-preserving stops. Change them with `sbx extend $id --hours 12` or `sbx idle $id 0`. ## Related - [Hosting](/docs/hosting) - [Snapshots](/docs/snapshots) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # Does a Cloud Sandbox Pause Itself When Idle? Auto-Pause and Wake Source: https://sandboxer.com/guides/idle-auto-pause-and-wake-on-use. Updated 2026-10-27. **Short answer:** Yes. A Sandboxer sandbox pauses itself after idleStopMinutes without activity (15 to 120 minutes by default, depending on your plan). Pausing is a stop with memory, so compute isn't billed. The next API call or request to one of its hosted URLs wakes it. Set it with sbx new --idle or sbx idle. ## At a glance | Topic | Detail | | - | - | | Setting | `idleStopMinutes` on the sandbox; `0` turns it off, maximum `10080` (7 days) | | Defaults | $20 plan 30, $100 and $500 plans 60, $2,000 plan 120 | | What a pause is | A stop with memory: files are kept, and running processes too when the wake lands on a machine with the same CPU type | | Cost while paused | No compute. The disk and memory image count toward your plan's included storage | | Wakes on | An API call that needs the VM, a request to a hosted port, `sbx ssh` | | Doesn't wake on | Raw SSH to the port, a private URL without its token | | Hard limit | Auto-stop (the TTL) still applies, whatever the idle setting | The pause is the same memory-preserving stop as `sbx stop`. That mechanism is covered in [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory). This page covers what triggers it on its own and what wakes it. ## Set the idle timeout ```bash sbx new --type small --idle 1 # pause after 1 minute without activity sbx idle $id 0 # never pause this one sbx info $id --json | jq '.sandbox | {idleStopMinutes, lastActivityAt}' ``` ```json { "idleStopMinutes": 0, "lastActivityAt": "2026-10-27T02:16:01.109726Z" } ``` `--idle` takes minutes; leave it out to get your plan's default. On the API the field is `idleStopMinutes` on `POST /sandboxes` and `PATCH /sandboxes/{id}`, and every sandbox object returns it with `lastActivityAt`. The [long-running tasks docs](/docs/long-running#idle-auto-pause-and-wake-on-use) have the curl form. Forks copy their source's setting. Sandboxes created before October 26, 2026 have it off until you set it. With snapshots off, or on an account with zero data retention, it's always `0`, because a stop there would discard the sandbox. ## Try it: pause after one minute, wake from a URL We ran this on October 27, 2026 on a `small` sandbox with the shortest timeout, 1 minute. The web server and the counter run as systemd units, not `sbx exec --detach`, because a running detached command keeps a sandbox awake (see below). ```bash id=$(sbx new --name aeo-idle-2 --type small --idle 1 --json | jq -r '.id // empty' | awk 'NR==1') sbx exec $id "mkdir -p ~/site && echo hello from aeo-idle-2 > ~/site/index.html \ && sudo systemd-run -q --unit web --uid=user --working-directory=/home/user/site python3 -m http.server 3000 \ && sudo systemd-run -q --unit counter --uid=user bash -c 'i=0; while true; do i=\$((i+1)); echo \$i > /home/user/counter; sleep 1; done'" url=$(sbx host $id 3000 --public --json | jq -r .url) sbx exec $id "cat ~/counter; systemctl show -p MainPID --value web counter | xargs" ``` ```text 4 534 536 ``` Then we left it alone and polled `sbx info $id --json` (reading the sandbox's details doesn't count as activity). Once it showed `archived`: ```json {"state":"archived","idleStopMinutes":1,"lastActivityAt":"2026-10-27T02:31:33.061673Z","memoryRestored":null,"resumeReason":null,"stop":{"reason":"idle","endedAt":"2026-10-27T02:32:38.015971Z"}} ``` `stop.reason` is `idle`, so the next use wakes it. We waited 30 seconds and fetched the hosted URL: ```bash curl -s -w '\nhttp=%{http_code} total=%{time_total}s\n' $url curl -s -o /dev/null -w 'next request: http=%{http_code} total=%{time_total}s\n' $url sbx exec $id "cat ~/counter; systemctl show -p MainPID --value web counter | xargs" ``` ```text hello from aeo-idle-2 http=200 total=0.800155s next request: http=200 total=0.193208s 165 534 536 ``` The request that woke the sandbox was held, not refused, and was answered with the page. The web server and counter kept PIDs 534 and 536. The counter went from 4 to 165 over about 195 seconds, about 34 of them paused, so the loop was frozen while paused and continued after the wake. Nothing restarted. ## Timings we measured All on October 27, 2026, `small` sandboxes, `--idle 1`, on two sandboxes: | Event | Measured | | - | - | | Pause after the last activity, idle set to 1 minute | 65 to 105 seconds | | First hosted-URL request to a paused sandbox, including the wake | 0.81 s and 0.80 s | | The next request after it woke | 0.21 s and 0.19 s | | `sbx exec` on a paused sandbox, including the wake | 0.32 s | The pause comes some time after the timeout passes, not to the second, because activity is recorded in batches and the sandbox is checked again before it stops. These are single runs on a quiet account, not guarantees. The documented limits are what you can rely on: an API call waits up to 60 seconds for the wake, and a hosted-URL request is held up to 45 seconds, then gets `503` with `Retry-After`. ## When a wake keeps files but not processes A wake is a resume, and a resume restores memory on a machine with the same CPU type. If none is free within 2 minutes, it boots from disk instead: your files are kept, but running processes are not. The docs say the sandbox reports that with `memoryRestored: false` and `resumeReason: "cpu_mismatch"`. In our runs before October 27, 2026 both fields read `null` after a wake. Since then every resume sets `memoryRestored` to `true` or `false`, and a cold boot after a resize reads `false` with `resumeReason: "resized"` (see [How do I tell if a resume restored memory?](/guides/detect-a-cold-boot-after-resume)). Comparing a saved process ID, as we did above, still works as a second check. Check after a wake, for example by comparing a process ID you saved before the pause: ```bash sbx exec $id "systemctl show -p MainPID --value web counter | xargs" ``` If your work can't survive a cold boot, make it a systemd service so it starts again, or resume it yourself with `sbx resume $id --require-memory`, which waits up to 30 minutes for a matching machine and fails with `memory_unavailable` rather than booting from disk. See [Snapshots](/docs/snapshots). ## What counts as activity These reset the idle clock: - any API call that reaches the VM: commands, files, processes, the terminal, `sbx ssh`, the desktop, agent prompts; - requests to its hosted URLs; - the end of a command or agent turn; - every start or resume. The sandbox also stays awake while any of these is true, however long it lasts: - a terminal or SSH websocket is open; - a command is running, detached ones too; - an agent turn runs, or someone is logged in over SSH; - the 1-minute load average is at least 0.5; - the file `~/.sandboxer/keep-awake` exists. We checked the detached-command rule: with `--idle 1`, `sbx exec $id --detach "sleep 120"` kept the sandbox `ready` for the whole two minutes, and it paused about 40 seconds after the command ended. So a dev server started with `sbx exec --detach` keeps its sandbox awake for as long as it runs. Start it as a systemd unit instead if you want the sandbox to pause and wake on traffic. ## Keep it awake for work the API can't see A process that streams from a model, or waits on a slow external job, can look idle. Create the keep-awake file: ```bash sbx exec $id "touch ~/.sandboxer/keep-awake" ``` With the file in place and `--idle 1`, our sandbox stayed `ready` for the nearly 3 minutes we watched. After `rm ~/.sandboxer/keep-awake` it paused about a minute and a half later, and the next `sbx exec` woke it with its counter still running. To turn pausing off for one sandbox instead, use `sbx idle $id 0`. ## FAQ ### Is a paused sandbox billed? Compute isn't billed while it's paused. Its disk and memory image count toward your plan's included storage, like any stopped sandbox. ### Does a sandbox I stopped myself wake on use? No. Only a sandbox paused for being idle wakes by itself. One stopped by you, by auto-stop or by billing stays stopped until you run `sbx resume`. ### Will a private hosted URL wake it? Only a request that carries the port's token (`?_token=`, its cookie or header). A request without it doesn't wake the sandbox. ### Can I SSH into a paused sandbox? `sbx ssh` wakes it. Raw SSH to the sandbox's port doesn't, because the port is closed while it's stopped. ### Does idle auto-pause replace auto-stop? No. Auto-stop is a separate hard deadline (`archiveAfter`, one hour by default). Push it out with `sbx extend $id --hours 12` or turn it off with `--no-auto-stop`. ## Related - [Long-running tasks](/docs/long-running) - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Snapshots](/docs/snapshots) - [Hosting](/docs/hosting) --- # Fix Exit Code 137 or a Full Disk in a Sandbox by Resizing It Source: https://sandboxer.com/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox. Updated 2026-10-27. **Short answer:** Exit code 137 means the kernel's OOM killer sent SIGKILL because the sandbox ran out of RAM; "No space left on device" (ENOSPC) means the disk is full. Fix both the same way, with sbx stop and then sbx resume --type and a bigger size. Your files stay; running processes don't, so restart the job. ## Which error do you have? | What you see | What happened | Confirm it with | | - | - | - | | `exit=137`, `Killed` from bash | The process used more memory than the sandbox has. 137 is 128 + 9, signal 9 being SIGKILL | `dmesg \| grep -i 'out of memory'` | | `No space left on device`, `OSError: [Errno 28]` | The root filesystem is full | `df -h /` shows `100%` | | `type_too_small` (HTTP 400) on resume | You asked for a smaller size. Moving to a smaller size isn't supported yet, even after you delete data | Keep the size, or create a new, smaller sandbox and copy what you need | The sizes, from the [machines docs](/docs/machines). Prices are per hour, billed per second while the sandbox runs: | Size | vCPU | RAM | Disk | Price per hour | | - | - | - | - | - | | `small` | 2 | 4 GB | 12 GB | $0.018 | | `default` | 4 | 8 GB | 50 GB | $0.036 | | `large` | 8 | 16 GB | 125 GB | $0.072 | | `xlarge` | 16 | 32 GB | 251 GB | $0.20 (plans from $100) | ## Reproduce an OOM kill on a small sandbox We ran everything below on a real `small` sandbox. First, how much memory and disk it has: ```bash sbx new --type small id=sb_5jn838r5rt # your sandbox id from `sbx new` sbx exec $id "free -h; df -h /" ``` ```text total used free shared buff/cache available Mem: 3.8Gi 306Mi 3.1Gi 352Ki 625Mi 3.5Gi Swap: 0B 0B 0B Filesystem Size Used Avail Use% Mounted on /dev/root 19G 5.5G 13G 30% / ``` There is no swap, so when RAM runs out the kernel kills a process instead of slowing down. This job holds 6 GB, 256 MB at a time, like a script loading a dataset that's too big: ```python # ~/job.py chunks = [] for i in range(24): chunks.append(bytearray(256 * 1024 * 1024)) print(f'{(i + 1) * 256} MB', flush=True) print('done') ``` ```bash sbx exec $id "python3 ~/job.py; echo exit=\$?" ``` ```text 256 MB 512 MB … 3328 MB 3584 MB exit=137 /bin/bash: line 1: 568 Killed python3 ~/job.py ``` The kernel log names the process and how much memory it held when it died: ```bash sbx exec $id "dmesg | grep -i 'out of memory'" ``` ```text [ 33.999539] Out of memory: Killed process 568 (python3) total-vm:3950072kB, anon-rss:3827584kB, file-rss:28kB, shmem-rss:0kB, UID:1000 pgtables:7556kB oom_score_adj:-900 ``` The first time we ran the job, `sbx exec` didn't print `exit=137`. It returned `The machine did not answer.` (`sandbox_direct_failed`), and the same `dmesg` line showed that the kernel had killed `python3`. A fix shipped later on 2026-10-27: in sandboxes created since then, an OOM-killed command exits 137 and the guest agent stays up (older sandboxes get the fix at their next boot). On an older sandbox, if an exec dies with no exit code while a job is allocating memory, check `dmesg` before you look anywhere else. ## Reproduce a full disk `fallocate` reserves space without writing it, so it fills a disk in a second: ```bash sbx exec $id "fallocate -l 13G ~/big.bin; echo exit=\$?; df -h /" ``` ```text fallocate: fallocate failed: No space left on device exit=1 Filesystem Size Used Avail Use% Mounted on /dev/root 19G 19G 0 100% / ``` The `fallocate` failed, but the space it had already taken stayed taken: `du` showed 14G in `/home/user`. From then on, every write fails: ```bash sbx exec $id "echo hello > ~/note.txt; echo exit=\$?; python3 -c \"open('out.csv','w').write('x'*10**8)\"" ``` ```text /bin/bash: line 1: echo: write error: No space left on device exit=1 Traceback (most recent call last): … OSError: [Errno 28] No space left on device ``` To find what is using the space, run `du -xh -d1 / | sort -h | tail`. If the big files are disposable, deleting them is the quick fix. If you need them, resize. ## Fix both: stop, then resume at a bigger size ```bash sbx stop $id --wait sbx resume $id --type default sbx exec $id "uptime; free -h; df -h /; ls -lh ~/big.bin" ``` ```text 02:42:59 up 0 min, 0 user, load average: 0.00, 0.00, 0.00 total used free shared buff/cache available Mem: 7.8Gi 332Mi 7.4Gi 360Ki 242Mi 7.4Gi Swap: 0B 0B 0B Filesystem Size Used Avail Use% Mounted on /dev/root 56G 19G 38G 33% / -rw-r--r-- 1 user user 13G Oct 27 02:42 /home/user/big.bin ``` The resume took about 3 seconds (one run, 2026-10-27). The same sandbox now has twice the RAM and a bigger disk, with the filesystem already expanded. The 13 GB file is still there. Now the job finishes, and writes work again: ```bash sbx exec $id "python3 ~/job.py | tail -3; echo exit=\${PIPESTATUS[0]}" sbx exec $id "echo hello > ~/note.txt; echo exit=\$?" ``` ```text 5888 MB 6144 MB done exit=0 exit=0 ``` The sandbox keeps its id, URL and files. `sbx info $id --json` showed `"type":"default","vcpu":4,"memoryGB":8,"diskGB":50`. ## What a resize does not keep A normal resume restores the VM's memory, so processes keep running, as long as a machine with the same CPU type is free; otherwise it boots from disk and reports `resumeReason: "cpu_mismatch"` ([snapshots docs](/docs/snapshots)). A resume with `--type` always boots from the disk, because the memory image belongs to the old size. You can see it above: `uptime` says `up 0 min`. Our run saw `memoryRestored: null` in the resume's JSON; that was before a fix shipped later the same night, and a resize resume now reports `memoryRestored: false` with `resumeReason: "resized"`. Enabled systemd services start on their own. Anything you started by hand, such as a dev server, a `tmux` session or the job itself, has to be started again. If the job was half done, make it resumable (write checkpoints to disk) before you rely on a resize. Sizes change only on resume or fork. `sbx fork $id --type default` gives you a bigger copy and leaves the original as it is. ## Shrinking back fails with type_too_small Growing works. Moving to a smaller size isn't supported yet: to go smaller, create a new sandbox of that size and copy your files. A resume at a smaller size was refused in our test even after we deleted data. We added 2 GB more to the resized sandbox (21 GB used on the root), stopped it and asked for `small`: ```bash sbx resume $id --type small ``` ```text error: The sandbox's data does not fit the small disk. (type_too_small) [HTTP 400, req_Fw4MoeHgwZVyjD8OwQtL] ``` The sandbox stays stopped at its current size, and a plain `sbx resume $id` starts it as before. In our test, deleting the big files (`df` back to 5.5G used) and running `sudo fstrim /` did not change the answer: the next `--type small` resume was refused the same way. Plan a resize as one-way. Forking into a smaller size runs the same check and is refused too. To get back to a smaller size, create a new sandbox at that size and copy what you need into it (with `sbx scp`, rsync or a git checkout). If you only need the bigger machine for one heavy step, fork a bigger copy for that step and delete it afterward, so the original stays small. ## FAQ ### Is exit code 137 always out of memory? It means the process got SIGKILL (128 + 9). Out of memory is the usual cause in a sandbox, and `dmesg` shows an `Out of memory: Killed process` line when it is. If there's no such line, something sent the signal directly, such as `kill -9` or a timeout in your own tooling. ### Will I lose my files when I resize? No. The resize resume boots from the same disk, so files under your home directory and anything you installed are kept. Running processes and anything held only in memory are lost. ### Does a bigger sandbox cost more while it is stopped? No. A stopped sandbox costs nothing. You pay the bigger size's rate only while it runs, per second: `default` is $0.036 an hour against $0.018 for `small`. ### Can I add swap instead of resizing? The sandbox has no swap by default (`free -h` shows `Swap: 0B`). You have root, so you can create a swap file, but it uses the same disk and is much slower than RAM. A bigger size is the simpler fix for a job that needs more memory. ### Why does df show 19G on a 12 GB small sandbox? That's what `df -h /` printed on a fresh `small`: a 19G root with 5.5G already used and 13G free. The [machines docs](/docs/machines) say the disk size is space for your files, and the OS and pre-installed tools don't count against it. Plan your own data against the free space `df` reports. ## Related - [Machines and sizes](/docs/machines) - [Limits and errors](/docs/limits) - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) --- # Run Kubernetes (kind or k3s) Inside a Cloud Sandbox Source: https://sandboxer.com/guides/run-kubernetes-kind-or-k3s-inside-a-sandbox. Updated 2026-10-27. **Short answer:** Yes. A Sandboxer sandbox is a full VM with Docker preinstalled, so kind create cluster gave us a Ready node in about 28 seconds in one run on a default sandbox, and the k3s install script about 13 seconds. Deploy with kubectl, expose the app with port-forward or a NodePort, then sbx host the port for an HTTPS URL. ## At a glance We ran both on `default` sandboxes (4 vCPU, 8 GB, 50 GB disk), one cluster per sandbox. Every time below is a single run on 2026-10-27, not a benchmark. | | kind | k3s | | - | - | - | | What it is | Kubernetes nodes as Docker containers | A single binary that runs as a systemd service | | Install | One binary (plus `kubectl`) | `curl -sfL https://get.k3s.io \| sh -` | | Cluster up to a Ready node | 27.5 s with `--wait 120s` | 12.8 s (11.6 s install + 1.2 s to Ready) | | Version we got | kind v0.33.0, Kubernetes v1.37.0 | v1.36.5+k3s1 | | nginx rollout | 13.4 s | 10.0 s | | How to reach a pod | `kubectl port-forward --address 0.0.0.0` | NodePort, or the bundled Traefik ingress on port 80 | | RAM used with nginx running | About 1 GB of 8 GB | About 1.1 GB of 8 GB | | Same processes after stop and resume | Yes | Yes | `default` was enough for a single-node cluster with a few pods. For multi-node kind clusters or heavier workloads, pick `large` (8 vCPU, 16 GB); we didn't test multi-node. ## kind kind runs each Kubernetes node as a Docker container, so it needs Docker and nothing else. Install a pinned release from the [kind quick start](https://kind.sigs.k8s.io/docs/user/quick-start/) and `kubectl` from the [Kubernetes docs](https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/): ```bash id=sb_k3v9x2mq # your sandbox id from `sbx new` sbx exec $id --timeout 300 "curl -sSLO https://dl.k8s.io/release/\$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl \ && sudo install -m 0755 kubectl /usr/local/bin/kubectl && kubectl version --client" # Client Version: v1.37.1 sbx exec $id --timeout 300 "curl -sSLo kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64 \ && chmod +x kind && sudo mv kind /usr/local/bin/ && kind version" # kind v0.33.0 go1.26.7 linux/amd64 ``` Pin the version. On the day we ran this, `https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64` downloaded `v0.34.0-alpha`, not the latest stable release. Create the cluster. Pass `--wait`, otherwise `kind create cluster` returns before the node is Ready: ```bash sbx exec $id --timeout 600 "time kind create cluster --name demo --wait 120s >/dev/null 2>&1; kubectl get nodes" # NAME STATUS ROLES AGE VERSION # demo-control-plane Ready control-plane 21s v1.37.0 # # real 0m27.542s ``` Without `--wait`, the first run took 22.8 seconds, including pulling the `kindest/node:v1.37.0` image, and `kubectl get nodes` showed `NotReady` for the next 20 seconds or so. Deploy nginx and wait for it: ```bash sbx exec $id "kubectl create deployment web --image=nginx --port=80 && kubectl rollout status deploy/web --timeout=180s && kubectl get pods -o wide" # deployment.apps/web created # deployment "web" successfully rolled out # NAME READY STATUS RESTARTS AGE IP NODE # web-7b45cc55bc-jbxhw 1/1 Running 0 14s 10.244.0.2 demo-control-plane ``` ### Put it on an HTTPS URL A kind NodePort listens on the node container's address (`172.18.0.2` here), not on the sandbox itself. kind's docs cover publishing node ports with [`extraPortMappings`](https://kind.sigs.k8s.io/docs/user/configuration/#extra-port-mappings). The quicker path is `kubectl port-forward` bound to `0.0.0.0`, run in the background, then `sbx host`: ```bash sbx exec $id --detach "kubectl port-forward deploy/web 8080:80 --address 0.0.0.0" sbx host $id 8080 --public # {"ok":true,"type":"port.hosted","port":8080,"url":"https://brave-exact-parrot-8080.box.sandboxer.com","access":"public",…} curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" https://brave-exact-parrot-8080.box.sandboxer.com # 200 0.215067s curl -sS https://brave-exact-parrot-8080.box.sandboxer.com | grep title # Welcome to nginx! ``` Leave off `--public` to get a private URL with a token instead (see [hosting](/docs/hosting)). To stop the port-forward later, kill it by the `pid` that `--detach` printed. `pkill -f port-forward` also matches the shell `sbx exec` runs it in, which ended our command with exit 143. ## k3s k3s needs no Docker. The [k3s quick start](https://docs.k3s.io/quick-start) install script downloads the binary, installs a `k3s` systemd service, enables it and starts it: ```bash sbx exec $id --timeout 600 "time (curl -sfL https://get.k3s.io | sh - 2>&1 | tail -5); \ time (until sudo k3s kubectl get nodes 2>/dev/null | grep -q \" Ready\"; do sleep 1; done); sudo k3s kubectl get nodes" # … # [INFO] systemd: Enabling k3s unit # [INFO] systemd: Starting k3s # real 0m11.604s # real 0m1.217s # NAME STATUS ROLES AGE VERSION # radiant-civic-saffron Ready 1s v1.36.5+k3s1 ``` The node takes the sandbox's subdomain as its name. Its `control-plane` role label appeared a few seconds later. Use `sudo k3s kubectl`, because the kubeconfig at `/etc/rancher/k3s/k3s.yaml` is readable only by root. Deploy nginx and expose it as a NodePort. A k3s NodePort listens on the sandbox itself, so you can host it directly: ```bash sbx exec $id "sudo k3s kubectl create deployment web --image=nginx --port=80 \ && sudo k3s kubectl rollout status deploy/web --timeout=180s" # deployment.apps/web created # deployment "web" successfully rolled out sbx exec $id "sudo k3s kubectl expose deploy web --type=NodePort --port=80 && sudo k3s kubectl get svc web" # service/web exposed # NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE # web NodePort 10.43.239.246 80:30572/TCP 0s sbx host $id 30572 --public curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" https://radiant-civic-saffron-30572.box.sandboxer.com # 200 0.202872s ``` A `curl` to the NodePort in the same command as `expose` returned no response. A few seconds later it returned `200`. ### Or use the bundled Traefik ingress k3s ships Traefik as its ingress controller, listening on port 80. Add an Ingress and host port 80: ```bash sbx exec $id "sudo k3s kubectl create ingress web --rule=/*=web:80" sbx host $id 80 --public curl -sS https://radiant-civic-saffron-80.box.sandboxer.com | grep title # Welcome to nginx! ``` In the first 20 seconds, `kubectl get pods -n kube-system` showed `helm-install-traefik` in `Error` with one restart. It moved to `Completed` on its own after the next retry, and Traefik started. Wait for it before you add an Ingress. ## Stop and resume keep the cluster running A stop saves the VM's memory along with its disk, so `sbx resume` brings back the running cluster rather than rebooting it (see [snapshots](/docs/snapshots)). We stopped and resumed both sandboxes: | | Before stop | After resume | | - | - | - | | kind: `kubectl port-forward` | PID 3325 | PID 3325, URL answered `200` at once | | kind: node | Ready | `NotReady` for about 20 seconds, then Ready | | kind: nginx pod | Running, 0 restarts | Running, 0 restarts | | k3s: `k3s server` | PID 728 | PID 728 | | k3s: node and pod | Ready, Running | Ready, Running, NodePort URL `200` | The `sbx resume` command took 3.7 s for the kind sandbox and 1.4 s for k3s (single runs, 2026-10-27). Memory is restored only on a machine with the same CPU type as the one the sandbox stopped on. If none is free within 2 minutes, the resume boots from disk instead: your files and pulled images survive, but running processes don't, and the sandbox shows `resumeReason: "cpu_mismatch"`. Pass `sbx resume --require-memory` to wait for a matching machine instead. A size change (`sbx resume --type large`) always boots from disk. After a boot from disk, k3s starts again because its installer enables the systemd unit. We didn't test whether a kind cluster comes back after a boot from disk, and you would need to start a port-forward again. ## FAQ ### Do I need a large sandbox to run Kubernetes? No. A single-node kind or k3s cluster with nginx used about 1 GB of the 8 GB on a `default` sandbox. Pick `large` for multi-node kind clusters or many pods. ### Does kind need privileged containers or the host's Docker socket? No host socket is involved. Each sandbox is its own VM with its own Docker daemon, and kind's privileged node containers run inside that VM, not on a shared host. ### Can I run kind and k3s in the same sandbox? We ran them in separate sandboxes. Both want the sandbox's ports and CPU, so one cluster per sandbox is the simpler setup. ### How do I share the app with someone? Run `sbx host` on the port and send the URL. Without `--public`, the URL carries a token and only people with the full link can open it. ### Does the cluster keep running if my laptop disconnects? Yes. The cluster runs in the sandbox, not on your machine. It keeps running until the sandbox stops: when you run `sbx stop`, at auto-stop, or at an idle pause. A stop keeps the cluster's memory. ## Related - [Docker and nested KVM inside an agent sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Snapshots](/docs/snapshots) --- # What Happens to My Sandboxes When I Cancel Sandboxer? Source: https://sandboxer.com/guides/what-happens-to-my-sandboxes-when-i-cancel. Updated 2026-11-02. **Short answer:** Cancelling a paid plan runs to the end of the period and deletes nothing. A trial that had already started is different: cancelling it ends it at once and permanently deletes every sandbox and snapshot on the account, so copy your work out first with sbx scp or a git bundle. This page restates what the [billing docs](/docs/billing), the [limits docs](/docs/limits#trials-that-had-already-started), the [snapshot docs](/docs/snapshots#how-long-a-stopped-sandbox-keeps-its-data) and the [Terms](/terms) (section 7, "Fees and billing") say. If anything here ever differs from them, they win. The second half is a save-your-work routine we ran on prod on 2026-10-29. ## At a glance | You do | When it takes effect | What happens to sandboxes and snapshots | Charge | | - | - | - | - | | Cancel a paid plan | At the end of the current period | Nothing is deleted | The plan runs to the end of the period and isn't refunded for the rest of it | | Leave a sandbox stopped | Its data is deleted 14 days after it last stopped (sandboxes created before October 29, 2026 are kept until you delete them) | That sandbox stays listed as expired; named snapshots are kept | None | | Go over your included storage | While you're over | Nothing is deleted, unless you turned on auto-delete | None: storage isn't billed | | Cancel a trial that had already started, or remove its only card | At once | All of them are permanently deleted, named snapshots included | None | ## Cancelling a paid plan Cancelling a paid plan runs to the end of the period, and nothing is deleted. The Terms add that the plan isn't refunded for the rest of the period, and included time doesn't roll over. ## If you're still on a trial New accounts don't get a trial any more; plans start at $20/mo. A trial that had already started keeps the terms it started with until it ends: - Cancelling it (**Cancel trial** on the plan card in Billing) ends it at once, and you aren't charged. - All of the account's sandboxes and snapshots, named snapshots included, are permanently deleted. - Removing your only card during the trial does the same. To change cards, add the new one before removing the old. - If you don't cancel, it becomes the $20/month plan when it ends, and your card is charged then. ## Save your work before you cancel Before you cancel a trial, or close your account, get your files onto your own machine: cancelling a trial deletes named snapshots too. This is the routine we ran on a `small` sandbox named `aeo-cancel-1`. ### 1. See what would be deleted Pipe `sbx` and it prints JSON. These two commands list every sandbox in any state and every named snapshot on the account: ```bash sbx list --all --json | jq -r '.sandboxes[] | [.id, .name, .state, .createdAt[:10]] | @tsv' sbx snapshots --json | jq -r '.named[] | [.name, .sourceSandboxId, .status] | @tsv' ``` ```text sb_y67z198atp aeo-region-3 ready 2026-10-29 sb_5kg5cv808k aeo-region-2 ready 2026-10-29 sb_sqh6m0xnep aeo-cancel-1 ready 2026-10-29 aeo-cancel-snap sb_sqh6m0xnep ready ``` Our test account is shared with other writers, so the first two sandboxes aren't ours. That is the point: when a trial is cancelled, the whole list goes, not only the sandbox you're thinking of. `--all` matters, because plain `sbx list` shows only sandboxes that are up or running, and stopped ones are deleted too. ### 2. Pack the project inside the sandbox Our sandbox had a small git repo in `~/app` with one committed file set and one file that was never committed (`notes-not-in-git.txt`). We made both a git bundle and a tarball: ```bash id=sb_sqh6m0xnep sbx exec $id "cd ~ && git -C app bundle create ~/app.bundle --all && tar czf ~/app.tar.gz app && sha256sum app.bundle app.tar.gz" ``` ```text d6078d874526d6fba54f7ea382eadfbb35fb5af45dd9b55badbf0da08cb784cf app.bundle a0a759794db15882e46fc6d879f934dc601dbbf4a2b7565c1d1550697486a2bc app.tar.gz ``` A bundle holds every branch and commit, and nothing else. The tarball holds the working tree as it is, including uncommitted files. Take the tarball if you're not sure everything is committed. ### 3. Copy them to your machine `sbx scp` copies in either direction through the same tunnel as `sbx ssh`: ```bash sbx scp $id:/home/user/app.bundle $id:/home/user/app.tar.gz ./ sha256sum app.bundle app.tar.gz ``` ```text d6078d874526d6fba54f7ea382eadfbb35fb5af45dd9b55badbf0da08cb784cf app.bundle a0a759794db15882e46fc6d879f934dc601dbbf4a2b7565c1d1550697486a2bc app.tar.gz ``` The hashes match the ones from inside the sandbox. You can also copy a folder as it is, with `sbx scp -r $id:/home/user/app ./app-copy`; ours came back with `.git`, `README.md`, `data`, `main.py` and `notes-not-in-git.txt`. ### 4. Check the copy before you cancel ```bash git clone -q app.bundle app-restored && git -C app-restored log --oneline tar tzf app.tar.gz | grep -c notes-not-in-git ``` ```text 7c06c9e first commit 1 ``` The clone has the commit, and the tarball has the uncommitted file. If the project lives in a git host already, `git push` from inside the sandbox does the same job. For large files, rsync and pulling files from a stopped sandbox without resuming it, see [move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox). When we were done we deleted the named snapshot and the sandbox (`sbx snapshot rm aeo-cancel-snap`, `sbx delete sb_sqh6m0xnep --yes`). `sbx info` then answered `404 not_found`. ## Storage, auto-delete and stopped sandboxes These rules apply whether or not you cancel. **The storage limit.** Each plan includes an amount of storage for stopped sandboxes and named snapshots (the amounts are in the [billing docs](/docs/billing#storage)). Storage isn't billed. At or over it, new sandboxes, forks and named-snapshot saves answer `402 storage_limit_reached` until you delete stopped sandboxes or named snapshots, or upgrade. Stopping, resuming and deleting still work, and going over never deletes anything unless you turn on auto-delete. **Auto-delete** is an account setting, off by default, under Billing, then Storage, in the dashboard. When it's on and you're over your included storage: 1. We pick the stopped sandboxes that bring you back under, least recently used first (by their last create, start or stop). 2. We email you the list with the date they'll be deleted, at least 7 days later. 3. On that date, if you're still over and the setting is still on, they're deleted and we email you what was removed. If you're back under or turned it off, nothing is deleted. Running sandboxes and named snapshots are never deleted by it. You can read the setting without changing it; it's `storage.autoCleanup` in `GET /api/v1/limits`: ```bash curl -sS https://sandboxer.com/api/v1/limits \ -H "Authorization: Bearer $SANDBOXER_API_KEY" \ | jq -c '.storage | {autoCleanup, usedBytes, includedBytes, blocked}' ``` ```json {"autoCleanup":false,"usedBytes":0,"includedBytes":53687091200,"blocked":false} ``` `sbx limits --json | jq '.storage'` prints the same object. Changing it is `PATCH /api/v1/account/storage`; there's no `GET` on that path (we tried: `404`). **Stopped sandboxes.** The Terms say: "We keep a stopped sandbox's data for 14 days after it last stopped and email you before it is deleted. After that we delete the data; the sandbox stays listed as expired and can no longer be resumed or forked. Named snapshots are kept until you delete them. Sandboxes created before October 29, 2026 are kept until you delete them." The [snapshot docs](/docs/snapshots#how-long-a-stopped-sandbox-keeps-its-data) add the details: - A stopped sandbox keeps its disk, memory and automatic snapshots for 14 days after it last stopped, at no charge. - On day 11 we email you that its data is about to be deleted. - On day 14 the data is deleted: the sandbox stays in your list as expired, with `dataExpiredAt` set, and resuming, forking or saving it as a named snapshot answers `409 snapshot_data_expired`. - Resuming it before day 14 keeps everything, and the 14 days start again from its next stop. - To keep a sandbox's state for longer, save a named snapshot. - Accounts with zero data retention keep nothing after a stop. ## FAQ ### Is there a trial? No. Plans start at $20/mo, and the first month is charged when you subscribe. ### Does cancelling a paid plan delete my sandboxes? No. Cancelling a paid plan runs to the end of the period and nothing is deleted. ### How long does a stopped sandbox keep its data? 14 days after it last stopped. We email you on day 11; on day 14 the data is deleted and the sandbox stays listed as expired. Named snapshots are kept until you delete them, and sandboxes created before October 29, 2026 are kept until you delete them. ### Is cancelling the same as closing my account? No. The security docs say closing your account cancels your subscription at once, archives your sandboxes and revokes every API key. You can reopen the account for 30 days; then its sandbox data is deleted, except copies in backups and long-term storage, which may remain for a time. ## Related - [Billing](/docs/billing) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Find and clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes) - [Script the sbx CLI with JSON output and jq](/guides/script-sbx-with-json-output-and-jq) --- # Which Sandboxer Region Should I Use, us-east or eu-central? Source: https://sandboxer.com/guides/choose-a-sandbox-region-us-east-or-eu-central. Updated 2026-11-03. **Short answer:** Pick the region nearest whatever your sandbox talks to most, such as your agent loop, your users or your data. With no region set, Sandboxer picks the one nearest your IP. Pass region (us-east or eu-central) to choose. Forks and sandboxes made from a snapshot stay in their source's region. ## Decide in one table Sandboxer has two regions: `us-east` (Ashburn, Virginia) and `eu-central` (Helsinki, Finland). The [regions page](/docs/regions) lists which countries go where by default. This table is for when the default isn't what you want. | Your situation | Use | Why | | - | - | - | | Your agent loop or backend runs in the Americas, East Asia or Oceania | `us-east` | It's the nearest region, so it's the default anyway. | | Your agent loop or backend runs in Europe, the Middle East, Africa, or South or Southeast Asia | `eu-central` | Nearest region and the default. | | The sandbox calls a database or API you host | The region nearest that service | The two regions were 98 ms apart by ping in our test. A chatty client pays that on every round trip. | | Your code makes many small `exec` calls | The region where they answer faster for you | Each call is a round trip. Time a few in each region, as shown below. | | You need `large` or `xlarge` to start at once | `us-east` | It keeps 2 `large` and 1 `xlarge` ready. `eu-central` keeps 1 `large` and no `xlarge`, so those boot in about 1.3–2 s. | | You start bursts of `small` sandboxes | Either | `eu-central` keeps 6 `small` ready and `us-east` keeps 4. Pools grow after a burst. | | Every sandbox in a burst must stay in one region | Pass `region` on every create | With no region, a burst that uses up your region's ready sandboxes can start some in the other region, as shown below. | | You fork a sandbox or start from a named snapshot | The source's region | You can't move it, as shown below. Sandboxes from before regions are in `eu-central`. | Pool sizes are from the [machine docs](/docs/machines#performance) on 2026-11-03. They change as we add machines. ## See where you'd land `GET /api/v1/regions` needs no key. With the CLI: ```bash sbx regions --json ``` ```json {"ok":true,"type":"region.list","default":"us-east","nearest":"us-east","regions":[{"available":true,"id":"eu-central","location":"Helsinki, Finland","name":"EU Central"},{"available":true,"id":"us-east","location":"Ashburn, Virginia","name":"US East"}]} ``` `nearest` is where a create with no region goes for the machine that asks. Check it from the machine that will create your sandboxes, not from your laptop. A CI runner or a cloud function can map to a different region. A create from our test machine with no region set, on 2026-11-03: ```bash sbx new --name aeo-region-refresh-13 --type small --no-env --ttl 900 --json | jq -c 'select(.event=="ready") | .sandbox | {id, region, regionSource}' ``` ```json {"id":"sb_byvgxlps5v","region":"us-east","regionSource":"nearest"} ``` Name a region and `regionSource` changes to `requested`: ```bash sbx new --region eu-central --type default --name aeo-region-refresh-14 --no-env --ttl 900 --json | jq -c 'select(.event=="ready") | .sandbox | {id, region, regionSource, type}' ``` ```json {"id":"sb_2h2j8um73o","region":"eu-central","regionSource":"requested","type":"default"} ``` The other values are `default`, `source` (taken from the sandbox or snapshot it was made from, as shown below) and `burst`. ## When a burst spills to the other region Since 2026-10-31, a create with no region can start in the other region during a burst. The [regions docs](/docs/regions#bursts-can-spill-to-the-other-region) describe it this way: when your region's ready sandboxes are used up, a new sandbox can start in the other region instead of waiting. It reports that region, with `regionSource: "burst"`. A sandbox you placed yourself never moves. Pass `region` on every create and the whole burst stays in that region. Those sandboxes report `regionSource: "requested"`. Leave it out and some can land in the other region, about 98 ms away by ping in our test. We didn't run a burst for this page. The [burst benchmark](/guides/how-fast-are-burst-sandbox-creates) measured one on 2026-10-31 from a client in Ashburn, Virginia. It started 40 `default` sandboxes at once with no region. 30 ran in `us-east` and 10 spilled to `eu-central`. All 40 were ready within 2.0 s, with a median of 1.87 s. To see whether a burst spilled, check `region` and `regionSource` on each sandbox you got back. ## When the region name is wrong A misspelled region fails before anything is created, and the error lists the valid ones: ```json {"code":"invalid_region","error":{"code":"invalid_region","details":{"regions":["eu-central","us-east"]},"message":"Unknown region \"ap-south\". Regions: eu-central, us-east (GET /api/v1/regions).","status":400}, …} ``` ## Pin the region in code If you want the same region every time, set it in code. Don't rely on `nearest`, which can change with where the code runs and, during a burst, spill to the other region. These are the exact scripts we ran on 2026-10-29, with `sandboxer-sdk` 0.1.3 for both TypeScript (npm) and Python (pip). ```typescript import { Sandboxer } from "sandboxer-sdk"; const sandboxer = new Sandboxer(); // reads SANDBOXER_API_KEY const { regions, nearest } = await sandboxer.api.listRegions(); console.log("nearest:", nearest, "| available:", regions.filter((r) => r.available).map((r) => r.id).join(", ")); const t0 = performance.now(); const sandbox = await sandboxer.create({ region: "eu-central", type: "small", name: "aeo-region-6", ttlSeconds: 600 }); console.log(sandbox.id, sandbox.info.region, sandbox.info.regionSource, `${Math.round(performance.now() - t0)} ms`); try { const run = await sandbox.exec("uname -r"); console.log("exec:", run.exitCode, run.stdout.trim()); } finally { await sandbox.delete(); } ``` ```text nearest: us-east | available: eu-central, us-east sb_xx8ii7gzjs eu-central requested 269 ms exec: 0 6.18.56-sandboxer ``` ```python import time from sandboxer import Sandboxer # pip install sandboxer-sdk with Sandboxer() as client: # reads SANDBOXER_API_KEY r = client.api.list_regions() print("nearest:", r.nearest, "| available:", ", ".join(x.id for x in r.regions if x.available)) t0 = time.perf_counter() sb = client.create(region="us-east", type="small", name="aeo-region-7", ttl_seconds=600) print(sb.id, sb.info.region, sb.info.region_source, f"{(time.perf_counter() - t0) * 1000:.0f} ms") try: run = sb.exec("uname -r") print("exec:", run.exit_code, run.stdout.strip()) finally: sb.delete() ``` ```text nearest: us-east | available: eu-central, us-east sb_s1zo5kkmtq us-east requested 169 ms ``` The Python run also printed `exec: 0 6.18.56-sandboxer`. On PyPI the package is `sandboxer-sdk` and you import it as `sandboxer`. A different project is published as `sandboxer`. ## What we measured in each region Measured on 2026-11-03 between 11:01 and 11:03 UTC, from our test machine in Florida, through `https://sandboxer.com`. A TCP connect to sandboxer.com took about 32 ms from there. Your numbers will differ with where you are and with load. | From our test machine | `us-east` | `eu-central` | | - | - | - | | Create to `ready` via the API, `default`, 5 runs each | 0.35 s median (0.35–0.37) | 0.35 s median (0.35–0.35) | | `sbx new --region … --json` to `ready`, `default`, 2 runs each | 0.25 s (0.245, 0.252) | 0.26 s (0.257, 0.261) | | `sbx exec true`, 20 runs over 2 sandboxes | 109 ms median (105–116) | 202 ms median (200–207) | Creation time didn't depend on the region. In both regions a create from the standard image takes a sandbox that's already booted, and every API create was `ready` by our second check. Each check cost one round trip plus a 50 ms pause, so those numbers are only accurate to about 0.15 s. For finer timing, see [how fast a sandbox starts](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). The `exec` round trip is the number that grows with distance. If your agent makes one call per tool use, 90 ms per call adds up over a long session. To time it from your own machine: ```bash for r in us-east eu-central; do id=$(sbx new --region $r --type small --no-env --ttl 600 --json | jq -r 'select(.event=="ready") | .id') for i in 1 2 3 4 5 6; do /usr/bin/time -f "$r %e s" sbx exec $id true >/dev/null; done sbx delete $id --yes done ``` ```text us-east 0.12 s us-east 0.10 s … eu-central 0.20 s eu-central 0.20 s … ``` That run, at 11:03 UTC on 2026-11-03, matched the table: about 0.10 s per call in `us-east` and 0.20 s in `eu-central`. ### Between regions, from inside a sandbox On 2026-10-29 we ran a `us-east` sandbox and an `eu-central` sandbox at once and measured the network distance with `ping` from inside each: | From | To | Average round trip | | - | - | - | | `us-east` sandbox | `eu-central` sandbox (IPv6) | 98.0 ms | | `us-east` sandbox | sandboxer.com | 0.47 ms | | `eu-central` sandbox | sandboxer.com | 96.9 ms | Two things follow from these numbers: - **API calls from inside a sandbox cost a round trip to sandboxer.com.** That was under 1 ms from `us-east` and about 97 ms from `eu-central`. It matters when code inside a sandbox calls the Sandboxer API, for example with its own `SANDBOXER_TOKEN`. - **Sandboxes in different regions are about 98 ms apart.** Keep sandboxes that talk to each other in the same region. ## What stays in its region A fork copies the disk where the source sandbox is. Asking for the other region is refused, and nothing is created. We tried it on 2026-10-29 with an `eu-central` sandbox, `sb_y67z198atp`: ```bash curl -sS -X POST https://sandboxer.com/api/v1/sandboxes/sb_y67z198atp/fork \ -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \ -d '{"region":"us-east"}' ``` ```json {"code":"region_mismatch","error":{"code":"region_mismatch","details":{"region":"eu-central"},"message":"A fork runs in its source's region, eu-central. Leave region out, or ask for eu-central.","status":400}, …} ``` Leave `region` out (`sbx fork` has no `--region` flag) and the fork lands next to its source. That's true even though `nearest` for our test machine is `us-east`: ```bash sbx fork sb_y67z198atp --wait --no-env --ttl 900 --json | jq -c 'select(.event=="ready") | .sandbox | {id, region, regionSource}' ``` ```json {"id":"sb_5kbpefv7we","region":"eu-central","regionSource":"source"} ``` A named snapshot behaves the same way, even after its source sandbox is deleted. We saved one from the `eu-central` sandbox and deleted the sandbox. Then we started from the snapshot: ```bash sbx snapshot sb_y67z198atp aeo-region-snap sbx delete sb_y67z198atp --yes sbx new --from aeo-region-snap --region us-east --json ``` ```json {"event":"error","error":"This snapshot is in region eu-central; a sandbox made from it starts there. Leave region out, or ask for eu-central.","code":"region_mismatch","status":400, …} ``` ```bash sbx new --from aeo-region-snap --no-env --json | jq -c 'select(.event=="ready") | .sandbox | {id, region, regionSource}' sbx exec sb_7vnrvv2ncs "cat ~/marker.txt" ``` ```json {"id":"sb_7vnrvv2ncs","region":"eu-central","regionSource":"source"} ``` ```text made-in-eu-central ``` To get a template into the other region, start a fresh sandbox there and rebuild it with the same setup script. Then save a snapshot of it there. [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot) shows the save-and-start pattern. Keep one snapshot name per region, for example `web-stack-us` and `web-stack-eu`. ## FAQ ### What region do I get if I don't pass one? You get the region nearest the IP address that made the create call. The response shows it as `regionSource: "nearest"`. Anything that can't be placed goes to `us-east`. During a burst, when that region has no ready sandboxes left, a create can start in the other region instead, shown as `regionSource: "burst"`. Pass `region` to keep every sandbox where you want it. ### Can I move an existing sandbox to the other region? No. A resume, a fork and a sandbox made from its snapshot all stay in its region. Asking a fork or a snapshot create for the other region returns `400 region_mismatch`. Rebuild in the other region instead. ### Do both regions keep the same sizes ready? No. Each region keeps its own number of each size ready (see the table above). A size with none ready boots instead, in about 1.3–2 s, so today an `xlarge` in `eu-central` boots while `us-east` keeps one ready. ### What if a region has no machine free? The create returns `503 region_unavailable` for that region. `GET /api/v1/regions` shows `available` for each one, so you can check before you create, or retry with the other region if your workload allows it. ## Related - [Regions](/docs/regions) - [How fast does a cloud sandbox start, resume and fork?](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork) - [How fast are burst sandbox creates?](/guides/how-fast-are-burst-sandbox-creates) - [Fork a running sandbox with memory](/guides/fork-a-running-sandbox-with-memory) - [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot) --- # How Do I Get Sub-Second Sandbox Creates From the SDK? Source: https://sandboxer.com/guides/get-sub-second-sandbox-creates-from-the-sdk. Updated 2026-11-03. **Short answer:** Use sandboxer-sdk 0.1.3 or later, reuse one client and use a size the region keeps ready. From a test machine in Florida on 2026-11-03, a default create was ready in a median 0.17 s (TypeScript) and 0.16 s (Python). SDK 0.1.2 took 0.59 s on 2026-10-29. Snapshot creates boot, about 1.1 s. ## What to do | Do this | Why | What we measured | | - | - | - | | Run SDK 0.1.3 or later | It asks the API to hold the ready check open instead of polling | `default` create: 0.59 s median on 0.1.2 (2026-10-29), 0.17 s on 0.1.3 (2026-11-03) | | Create one client and reuse it | Only the first request pays for the TLS connection | 2026-11-03: first `POST` of a run 93–124 ms, every later one 31–33 ms | | Start several creates at once | They overlap, so the batch takes about as long as one | 2026-11-03: 3 `default` creates at once, all ready within 0.30 s | | Use `small`, `default` or `large` from the standard image | Those come from the region's pool of booted sandboxes | Sizes and images not in the pool boot: about 1 s or more | | Keep snapshot creates for when you need the saved disk | A create from a snapshot always boots | 2026-11-03: 1.06 s median from a named snapshot | The numbers below are ours, from one place at one time. Results vary by load and where you are, so time your own creates (the script is [below](#time-it-yourself)). ## What changed in SDK 0.1.3 `create()` makes the sandbox with `POST /sandboxes`, then waits until it is `ready`. In 0.1.2 that wait was a poll: one `GET` at once, which still saw `provisioning`, then the next one 0.5 s later. A pooled sandbox is ready well before that, so most of what you waited for was the gap between polls. In 0.1.3, both SDKs send `GET /sandboxes/{id}?waitFor=ready&timeoutSeconds=…` instead. The API holds that request while the sandbox is starting and answers the moment it is up, so `create()` returns one round trip after the sandbox is ready. Against an API that doesn't hold the request, 0.1.3 polls from 100 ms. Nothing in your code changes: the same `create()` call gets faster when you upgrade. These are the requests one `create()` made, logged from our client (`default`, `us-east`, an already open connection). The 0.1.2 run is from 2026-10-29; the 0.1.3 run, which also passed a `name`, is from 2026-11-03: ```text TypeScript 0.1.2, 2026-10-29 POST /api/v1/sandboxes 202 31ms GET /api/v1/sandboxes/{id} 200 29ms (still provisioning) GET /api/v1/sandboxes/{id} 200 30ms (0.5 s later: ready) total 593 ms Python 0.1.3, 2026-11-03 POST /api/v1/sandboxes 202 31ms PATCH /api/v1/sandboxes/{id} 200 29ms (sets the name) GET /api/v1/sandboxes/{id}?waitFor=ready 200 95ms total 157 ms ``` Upgrade with: ```bash npm i sandboxer-sdk@latest pip install -U sandboxer-sdk ``` Both were still at 0.1.3, the latest, on 2026-11-03. The Python package is `sandboxer-sdk` and you import it as `sandboxer`. ## Measured on prod, November 3, 2026 | | | | - | - | | When | 2026-11-03, 11:01 to 11:03 UTC | | From | A test machine in Florida. A request to `https://sandboxer.com` took about 30 ms once connected | | Region | `us-east`, the one `GET /regions` reported as nearest | | SDKs | `sandboxer-sdk` 0.1.3, the latest, from npm (Node 24) and from PyPI (Python 3.13) | | What a number means | Wall time around one `create()` call until it returned with the sandbox `ready` | | Runs | One client per run, one sandbox at a time. Every create passed a `name`, which adds one `PATCH` of about 30 ms. Each sandbox was deleted, and confirmed gone, before the next | **One create at a time.** All times in milliseconds. | SDK | Size | Runs | Median | Max | | - | - | - | - | - | | TypeScript 0.1.3 | `default` | 8 | 172 | 240 | | Python 0.1.3 | `default` | 8 | 165 | 225 | | TypeScript 0.1.3 | `small` | 3 | 177 | 268 | | Python 0.1.3 | `small` | 3 | 181 | 236 | | TypeScript 0.1.3 | `large` | 3 | 166 | 290 | | Python 0.1.3 | `large` | 3 | 140 | 234 | How to read it: - **Every create came from the pool.** All 28 were ready in 0.11 to 0.29 s. None booted, including two runs of 3 `large` creates in a row, a size `us-east` keeps 2 of. - **The max is the first run.** The first create of each run paid for the TLS connection: its `POST` took 93 to 124 ms, later ones 31 to 33 ms. Leave out the first create of each run and the slowest was 0.19 s. - **Older figures.** On 2026-10-29, with the same SDK and no `name`, the `default` medians were 0.16 s (TypeScript) and 0.15 s (Python), and 4 of 55 creates booted (1.25 to 1.99 s), three of them `large` creates run back to back. SDK 0.1.2, which polls every 0.5 s, came back at about 0.59 s every time. We didn't re-run 0.1.2. We also tried `create({ wait: false })` followed by `waitUntilReady()`, 5 times. `create` returned in 62 ms (159 ms on the first, new connection), including the `PATCH` for the name, and the `waitUntilReady()` request was then held 72 to 110 ms because the sandbox wasn't ready yet. The total to `ready` was a median 160 ms, about the same as a plain `create()`. `wait: false` only helps if you have other work to do before you use the sandbox. ## Create in parallel Several creates at once take about as long as one, as long as the region has that many ready. Our three-sandbox examples below were ready in 0.26 to 0.30 s. For bigger bursts (20 and 40 at once), with method and script, see [how fast are burst sandbox creates](/guides/how-fast-are-burst-sandbox-creates); a burst bigger than what's ready boots the rest. This is the TypeScript we ran on 2026-11-03, with three sandboxes: ```typescript import { Sandboxer } from "sandboxer-sdk"; const client = new Sandboxer(); // reads SANDBOXER_API_KEY; reuse it for every create const t0 = performance.now(); const sandboxes = await Promise.all( [1, 2, 3].map(async (i) => { const sb = await client.create({ type: "default", ttlSeconds: 600, noEnv: true, name: `aeo-subsec-ts-${i}` }); console.log(`${sb.id} ready after ${((performance.now() - t0) / 1000).toFixed(2)} s`); return sb; }), ); const { stdout } = await sandboxes[0].exec("nproc"); console.log("nproc:", stdout.trim()); await Promise.all(sandboxes.map((sb) => sb.delete())); ``` ```text sb_k5izyiq9hm ready after 0.26 s sb_e0xgo1d508 ready after 0.27 s sb_ef7une7vfn ready after 0.30 s nproc: 4 ``` And the same in Python, with `AsyncSandboxer`: ```python import asyncio, time from sandboxer.aio import AsyncSandboxer async def main(): async with AsyncSandboxer() as client: # one client, one connection pool t0 = time.perf_counter() async def one(i): sb = await client.create(type="default", ttl_seconds=600, no_env=True, name=f"aeo-subsec-py-{i}") print(f"{sb.id} ready after {time.perf_counter() - t0:.2f} s") return sb sandboxes = await asyncio.gather(*(one(i) for i in range(1, 4))) r = await sandboxes[0].exec("nproc") print("nproc:", r.stdout.strip()) await asyncio.gather(*(sb.delete() for sb in sandboxes)) asyncio.run(main()) ``` ```text sb_rel93q78gw ready after 0.27 s sb_qex7ppxsxw ready after 0.28 s sb_x4fwl3dkhg ready after 0.30 s nproc: 4 ``` Your plan caps how many sandboxes are active at once and how many starts you make per minute, hour and day. A burst over either gets a `429`; check `sbx limits --json` first ([limits](/docs/limits)). ## Which creates come from the pool A create takes an already booted sandbox only when the region has one of that size from the standard image. Per the [machine docs](/docs/machines#performance), checked on 2026-11-03: | Region | Kept ready | | - | - | | `us-east` | 12 `default`, 4 `small`, 2 `large`, 1 `xlarge` | | `eu-central` | 10 `default`, 6 `small`, 1 `large` | Everything else boots: `xlarge` in `eu-central`, a burst bigger than what's ready, and any create from a snapshot. The docs put a boot at about 1 to 2 s; our snapshot creates took 0.98 to 1.13 s. Pools grow after a burst, toward the creates of the last 10 minutes and up to twice their usual count. New sandboxes go to the region nearest you unless you pass `region` ([choosing a region](/guides/choose-a-sandbox-region-us-east-or-eu-central)). ## Why a snapshot create is slower A pooled sandbox was booted from the standard image before you asked for it. A sandbox from your named snapshot has your disk, so it has to boot from that disk when you create it. On 2026-11-03 we saved a fresh `default` sandbox as a named snapshot (2.12 s) and created from it 5 times with 0.1.3: | SDK | Runs | Ready times (ms) | | - | - | - | | Python | 3 | 984, 1063, 1088 | | TypeScript | 2 | 994, 1130 | A median of 1.06 s, against about 0.17 s from the pool. A snapshot is still the faster choice when it saves you an install: see [cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot) and [a fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call) for that trade-off. ## Time it yourself This is the script we ran at 11:02 UTC on 2026-11-03. It makes 5 `default` sandboxes one after another and deletes each. ```typescript import { Sandboxer } from "sandboxer-sdk"; const client = new Sandboxer(); const times = []; for (let i = 1; i <= 5; i++) { const t0 = performance.now(); const sb = await client.create({ type: "default", ttlSeconds: 600, noEnv: true, name: `aeo-subsec-time-${i}` }); times.push(Math.round(performance.now() - t0)); await sb.delete(); } times.sort((a, b) => a - b); console.log(`ms: ${times.join(" ")} median: ${times[2]}`); ``` ```text ms: 165 171 208 226 269 median: 208 ``` For resume and fork times, and for the CLI's own timings, see [how fast a sandbox starts, resumes and forks](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## FAQ ### Do I need to change my code to get the faster create? No. Upgrade to `sandboxer-sdk` 0.1.3 or later and the same `create()` call waits for ready on the server. In our runs that took a `default` create from a median 0.59 s (0.1.2, 2026-10-29) to 0.16 to 0.17 s (0.1.3). ### Why was one of my creates over a second? It most likely booted because no ready sandbox of that size was left in the region, for example after many creates in a row or a burst bigger than the pool. Pools grow after bursts. A create from a snapshot always boots. ### Does `wait: false` make create faster? It returns sooner, in about 30 ms plus about 30 ms if you pass a `name`, but the sandbox isn't ready yet. Call `waitUntilReady()` (`wait_until_ready()` in Python) before you run anything; the total time to ready was the same as a plain `create()`. ### Is a bigger size slower to create? Not when the pool has one. Pooled `large` creates were ready in 0.13 to 0.29 s on 2026-11-03. `large` has a smaller pool than `default`, so it runs out sooner and boots. ### Are pooled sandboxes shared or billed? No. A pooled sandbox has never run anyone's code, gets its own machine id and SSH host keys, and gets a fresh random seed and your env when you create it. It isn't billed while it waits ([machine docs](/docs/machines#performance)). ## Related - [How fast does a cloud sandbox start, resume and fork?](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork) - [Should each agent tool call get a fresh sandbox?](/guides/a-fresh-sandbox-per-tool-call) - [Machine sizes and performance](/docs/machines) - [TypeScript SDK](/docs/sdk-typescript) --- # How Do I Reset a Cloud Sandbox to a Clean State Between Agent Runs? Source: https://sandboxer.com/guides/reset-a-sandbox-to-a-clean-state. Updated 2026-10-30. **Short answer:** There is no restore-into-the-same-sandbox command. Save a named snapshot of the clean state once, then start each agent run with sbx new --from that name and delete the dirty sandbox. On prod on 2026-10-30 that took 1.4 seconds and matched the baseline exactly; in-place cleanup with git clean left a running server and installed packages behind. ## Results We prepared one `small` sandbox, saved it, let a simulated agent run dirty a copy, and then tried each way back to clean. Every timing is one run on prod on 2026-10-30 (us-east), measured around the CLI call from outside the sandbox. Times vary with load and with how much your setup does. | Way back to clean | Time measured | Clean afterwards? | Id and subdomain | Memory and processes | What you keep around | | - | - | - | - | - | - | | (a) `sbx new --from `, delete the dirty one | 1.4 s create, 2.1 s delete | Yes: 0 lines differ from the baseline | New | None: boots fresh from disk | One named snapshot | | (b) `sbx fork` a clean parent kept stopped | 1.2 s | Yes: 0 lines differ | New | None: a fork copies the disk only | One stopped sandbox | | (c) `sbx new` from the base image, re-run setup | 0.2 s create + 8.4 s setup | Yes, if setup is reproducible | New | None | Nothing | | (d) Clean up in place (`git reset`, `git clean`, `rm`) | 0.1 s | No: 1,075 lines differ, and a server is still running | Same | Same: that is the problem | Nothing | What the docs say about the cost of keeping a clean state around: named snapshots have no separate charge, and what they store counts toward your plan's included storage. A stopped sandbox doesn't run, so it isn't billed for time, but its disk, memory and automatic snapshots also count toward included storage ([Billing](/docs/billing#storage)). Our setup's named snapshot showed `sizeBytes` of 20,147,339,264, which is the disk size; storage is measured as the data beyond the shared OS image. ## Is there a "restore snapshot into the same sandbox"? No. We checked `sbx --help`, the [Snapshots](/docs/snapshots) and [CLI](/docs/cli) docs, and the public API routes on 2026-10-30. The operations on an existing sandbox are stop, resume, fork, recover, rename, host, exec and delete. None of them rolls a sandbox's disk back to a named snapshot: - `sbx resume` restores the sandbox's own last stop, so it brings back the dirty state you stopped. - `sbx recover` power-cycles an unresponsive machine "without touching its disk". - `sbx snapshot pull` downloads a snapshot's files to your computer; it doesn't write them into a sandbox. So a reset is always a swap: a new sandbox from the clean state replaces the dirty one. If other systems need a stable handle, keep that handle outside the sandbox id (see [Keep a stable handle](#keep-a-stable-handle-while-you-swap) below). ## How we tested ### 1. Prepare the clean state We created a `small` sandbox from the base image and ran this setup script with `sbx exec --timeout 300 "$(cat setup.sh)"`: ```bash set -e sudo apt-get install -y -q sqlite3 >/dev/null 2>&1 || { sudo apt-get update -q >/dev/null && sudo apt-get install -y -q sqlite3 >/dev/null; } python3 -m venv ~/venv && ~/venv/bin/pip install -q requests==2.32.3 mkdir -p ~/project && cd ~/project git init -q -b main printf 'node_modules/\n*.log\n' > .gitignore echo 'baseline' > README.md echo 'LOG_LEVEL=info' > app.conf git add . && git -c user.email=ci@example.com -c user.name=ci commit -qm baseline echo "setup done" ``` ```text $ sbx new --name aeo-reset-1 --type small # 0.23 s $ sbx exec sb_rxvpdoqvj3 --timeout 300 "$(cat setup.sh)" setup done exit=0 # 8.36 s ``` Then we saved it under a name and stopped it, so the same sandbox could also serve as the clean parent for method (b): ```bash sbx snapshot sb_rxvpdoqvj3 aeo-reset-clean # 2.13 s, status "ready" sbx stop sb_rxvpdoqvj3 --wait # 2.17 s, state "archived" ``` Saving a named snapshot is covered in [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template); naming it after your lockfile hash is in [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot). ### 2. Record a baseline you can diff "Clean" needs a definition you can check. We captured a checksum of every file under `/home/user`, `/etc` and `/usr/local`, the installed apt and pip packages, and the user-space processes. We saved the output on our own machine, not in the sandbox, so the baseline can't be dirtied with it. ```bash title="manifest.sh" cd / find /home/user /etc /usr/local -xdev -type f 2>/dev/null | grep -v -E '^/home/user/\.(bash_history|cache/pip)' | sort | xargs -d '\n' md5sum 2>/dev/null | awk '{print $2, substr($1,1,8)}' dpkg-query -W -f='pkg ${Package} ${Version}\n' ~/venv/bin/pip freeze 2>/dev/null | sed 's/^/pip /' ``` ```bash title="ps.sh (user-space processes, kernel threads left out)" ps --ppid 2 -p 2 --deselect -o user=,args= | grep -v -E ' (ps|grep|sort|bash -c) ' | grep -v -E '^\S+\s+(ps|sort)( |$)' | sort ``` ```bash sbx exec sb_rxvpdoqvj3 --timeout 120 "$(cat manifest.sh)" > base.manifest # 18,100 lines sbx exec sb_rxvpdoqvj3 "$(cat ps.sh)" > base.ps ``` The clean process list had 11 entries: systemd, journald, logind, udevd, dbus, sshd, two agettys, containerd, dockerd and `sbx-guest`. Every new sandbox gets its own identity, so a few files always differ from the baseline: `/etc/hostname`, `/etc/hosts`, `/etc/machine-id`, the SSH host keys, `/etc/sandboxer/*` and `/etc/profile.d/sandboxer.sh`. Our compare script leaves those out: ```bash title="cmp.sh" # usage: bash cmp.sh other.manifest other.ps diff base.manifest "$1" | grep '^[<>]' | grep -v -E '/etc/(hostname|hosts|machine-id|profile.d/sandboxer.sh|sandboxer/|ssh/ssh_host)' | awk '{print $1, $2, $3}' diff base.ps "$2" | grep '^[<>]' true ``` ### 3. A simulated agent run We forked the stopped parent (method (b), below), checked the fork was clean, and then ran this in it, the kind of residue a coding agent leaves: ```bash title="agent-run.sh" cd ~/project echo 'patched by agent' >> README.md # tracked file changed sed -i 's/info/debug/' app.conf # tracked config changed mkdir -p src node_modules/left-pad && echo 'print(1)' > src/new.py && echo x > node_modules/left-pad/index.js && echo trace > debug.log mkdir -p ~/.cache/agent && echo '{"step":7}' > ~/.cache/agent/state.json # outside the repo echo 'export HTTP_PROXY=http://127.0.0.1:9' >> ~/.bashrc # shell config git config --global user.name agent # global git config sudo apt-get install -y -q tree >/dev/null 2>&1 # system package ~/venv/bin/pip install -q rich # Python package setsid nohup python3 -m http.server 8000 >/dev/null 2>&1 < /dev/null & # background process echo agent run done ``` After it, 1,074 manifest lines differed from the baseline (most of them the files of `rich` and its dependencies), plus one new process: ```text > user python3 -m http.server 8000 > /home/user/.bashrc 474f6a60 > /home/user/.cache/agent/state.json 3f927761 > /home/user/.gitconfig 7e9b10f3 > /home/user/project/app.conf 0730c1d5 > /home/user/project/debug.log c96da292 > /home/user/project/README.md ce2ac469 > /home/user/project/src/new.py dee5c469 > pkg tree > pip rich==15.0.0 … ``` ## (a) Start fresh from the named snapshot ```bash sbx new --from aeo-reset-clean --name aeo-reset-3 # 1.36 s, ready sbx delete sb_b2jisu2zjb --yes # 2.13 s, the dirty sandbox ``` ```text $ bash cmp.sh fresh.manifest fresh.ps | wc -l 0 $ sbx exec sb_8nag6lb6k5 'sqlite3 --version | cut -d" " -f1; ~/venv/bin/python -c "import requests;print(requests.__version__)"; cat ~/project/app.conf; command -v tree || echo no-tree' 3.45.1 2.32.3 LOG_LEVEL=info no-tree $ sbx info sb_b2jisu2zjb {"event":"error","error":"Sandbox not found","code":"not_found","status":404,…} ``` Zero lines differ: same files, same packages, same 11 processes. This is the default to reach for. The named snapshot is independent of the sandbox it came from, so you can delete that sandbox and keep only the snapshot. You don't need to wait for the delete before starting the next run; [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call) covers running this per call instead of per run. ## (b) Fork a clean parent that stays stopped `sbx fork --help` says a fork copies "a sandbox from its latest snapshot into a new, independent sandbox. The source is never changed." For a stopped parent that's its stop snapshot, so forking doesn't resume or touch the parent: ```bash sbx fork sb_rxvpdoqvj3 --name aeo-reset-2 --wait # 1.25 s, ready ``` ```text {"id":"sb_b2jisu2zjb","state":"ready","subdomain":"lively-carefree-maple","memoryRestored":null} ``` `memoryRestored` is null because a fork copies the disk only: the parent's memory image stays with the parent. The fork's diff against the baseline was 0 lines and its process list was identical. The parent was still `archived` afterwards. The catch is discipline. If anyone resumes the parent and works in it, every later fork inherits that work. A fork of a running sandbox copies its disk as it is at that moment ([Fork a running sandbox](/guides/fork-a-running-sandbox-with-memory) shows what does and doesn't come across). A named snapshot can't drift that way, which is why (a) is the safer default. Like any sandbox, a fork gets the default one-hour auto-stop unless you pass `--ttl`. ## (c) Recreate from the base image and re-run setup This is how we built the parent: 0.23 s to create plus 8.36 s for our small setup. Your setup is the variable: a real project's installs can take far longer. It also only gives the same result if the setup is reproducible: pin versions (we pinned `requests==2.32.3`), because `apt-get install` and unpinned `pip install` take whatever is current that day. Use it when the setup is quick, or when you want every run to pick up the latest packages on purpose. Nothing is stored between runs. ## (d) Clean up in place, and why it falls short ```bash sbx exec sb_b2jisu2zjb 'cd ~/project && git reset -q --hard && git clean -fdxq && rm -rf ~/.cache/agent && git status --short && echo cleaned' ``` ```text cleaned # 0.13 s, and git status is empty ``` `git status` looked perfect. The diff against the baseline still had 1,075 lines: ```text > user python3 -m http.server 8000 > /home/user/.bashrc 474f6a60 > /home/user/.gitconfig 7e9b10f3 > /home/user/project/.git/ORIG_HEAD 343d928a > /home/user/project/.git/index ea34a9e6 > pkg tree > pip rich==15.0.0 > pip Pygments==2.21.0 … ``` `git clean` only knows about the repository. It can't see a process still listening on port 8000, a proxy line in `~/.bashrc` that the next run's shell will pick up, a global git identity, or packages installed with apt or pip. It also changed the repository's own history files (`ORIG_HEAD`, the reflog). To make in-place cleanup reliable, you'd need to know everything the agent touched, and that's exactly what you don't know after an untrusted run. Use it only between steps of the same trusted task. ## Keep a stable handle while you swap Every swap gives you a new sandbox id and a new subdomain. If a queue, a dashboard or a user holds a reference, point it at something you control: - **A label.** Set labels through the REST API and look the current sandbox up by label. A `PATCH` replaces the whole set ([labels](/docs/api#labels)). - **The subdomain**, if a URL must stay the same. `sbx rename --subdomain` moves every hosted URL and the SSH endpoint at once. ```bash # label the new sandbox curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/sb_8nag6lb6k5 \ -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \ -d '{"labels":{"worker":"aeo-reset","slot":"1"}}' # {"id":"sb_8nag6lb6k5","labels":{"slot":"1","worker":"aeo-reset"}} (from .sandbox) # find the current one curl -sS -G https://sandboxer.com/api/v1/sandboxes \ -H "Authorization: Bearer $SANDBOXER_API_KEY" --data-urlencode "labels=worker=aeo-reset,slot=1" # [{"id":"sb_8nag6lb6k5","name":"aeo-reset-3","state":"ready"}] (from .sandboxes) ``` One thing we found on prod on 2026-10-30: a deleted sandbox keeps its subdomain. Renaming the new sandbox to the deleted one's subdomain failed: ```text $ sbx rename sb_8nag6lb6k5 --subdomain lively-carefree-maple {"event":"error","error":"The subdomain \"lively-carefree-maple\" is taken.","code":"subdomain_taken","status":409,…} ``` Renaming a live sandbox away does free its subdomain: we renamed one sandbox to `aeo-reset-w1` and then gave its old subdomain to another sandbox. So, to keep a URL across a swap, follow this order: 1. Create the new sandbox from the snapshot. 2. Rename the dirty sandbox's subdomain to a throwaway name. 3. Rename the new sandbox to the stable subdomain. 4. Host its ports again with `sbx host`. 5. Delete the dirty sandbox. We ran each of these commands on prod, though not as one end-to-end swap. A hosted route follows a rename: after the rename, our private port answered `401` without its token and `302` with it on the new subdomain, and `404` on the old one. A private port on a new sandbox gets a new token, so links that include `_token` change with every swap; a public port keeps the same URL. `--name` is a display name only. `sbx info aeo-reset-3` returned `not_found`, so don't use the name as your lookup key. ## FAQ ### Can I roll a sandbox back to a snapshot and keep its id? No. No CLI command or API operation restores a named snapshot into an existing sandbox. Create a new sandbox with `sbx new --from `, delete the old one, and keep a stable label or subdomain if something needs a fixed handle. ### Does stop and resume reset the sandbox? No. `sbx resume` restores the sandbox's memory and disk as they were at the stop, so the dirty files and the running processes come back too. Stop and resume is for pausing work, not undoing it. ### Is a fork or a named snapshot faster for resets? In our runs on 2026-10-30 they were close: 1.25 s for a fork of a stopped parent and 1.36 s for `sbx new --from`. Times vary. Pick the named snapshot when you can, because nobody can accidentally resume and change it. ### Is `git clean -fdx` enough between agent runs? Only for files inside the repository. In our test it left a background server running, a changed `~/.bashrc` and `~/.gitconfig`, and new apt and pip packages, 1,075 differing lines in all. ### What does keeping a clean state cost? A named snapshot has no separate charge; what it stores counts toward your plan's included storage. A stopped parent isn't billed for time, but its disk, memory and snapshots count toward included storage too. See [Billing](/docs/billing#storage). ## Related - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) - [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot) - [Can you fork a running sandbox with memory?](/guides/fork-a-running-sandbox-with-memory) - [Snapshots](/docs/snapshots) --- # How Do I Run Ollama on CPU in a Cloud Sandbox? Source: https://sandboxer.com/guides/run-ollama-on-cpu-in-a-cloud-sandbox. Updated 2026-10-27. **Short answer:** Install Ollama with its Linux script in a default sandbox, pull a small model such as qwen2.5:0.5b, and bind it to 0.0.0.0 with a systemd override. Then sbx host the port for a private HTTPS URL. There is no GPU: expect about 30 to 55 tokens per second for 0.5B and 13 to 22 for 1B. ## At a glance We ran everything below on October 27, 2026, on a `default` sandbox (4 vCPU, 8 GB RAM). For the last test we resumed it as `large` (8 vCPU, 16 GB RAM). Ollama `0.40.11`. **CPU only:** a sandbox has no GPU, and Ollama runs every layer on the CPU (`ollama ps` shows `100% CPU`). | Step | What we saw | | - | - | | Install script | 17 s, creates an enabled `ollama` systemd service on `127.0.0.1:11434` | | `ollama pull qwen2.5:0.5b` / `llama3.2:1b` | 397 MB in 7 s / 1.3 GB in 16 s | | Generation speed, `default` | `qwen2.5:0.5b` 28 to 57 tokens/s, `llama3.2:1b` 13 to 19 tokens/s | | Generation speed, `large` | `qwen2.5:0.5b` 50 to 59 tokens/s, `llama3.2:1b` 17 to 22 tokens/s | | `sbx host --localhost` with Ollama on 127.0.0.1 | `403` from Ollama | | `OLLAMA_HOST=0.0.0.0:11434`, then `sbx host` | `401` without the token, `200` with it | | Short request through the hosted URL | 0.3 to 0.55 s round trip | | `sbx stop --wait` + `sbx resume` | `memoryRestored: true`, same PID, model still loaded | | Cold boot (resume as `large`) | Service came back on its own, first request reloaded the model (1.4 s) | The speed ranges are real. The same prompt on the same sandbox varied by up to 2x between runs a minute apart. Plan for the low end. ## 1. Create a sandbox and install Ollama Use `default`. `small` has 4 GB of RAM, which is tight for anything above 1B. Install with the script from [Ollama's Linux docs](https://docs.ollama.com/linux): ```bash sbx new --name ollama --type default id=sb_o32n3ltk3i # the id that sbx new printed sbx exec $id "curl -fsSL https://ollama.com/install.sh | sh" ``` ```text WARNING: Unable to detect NVIDIA/AMD GPU. Install lspci or lshw to automatically detect and install GPU dependencies. >>> Installing ollama to /usr/local >>> Downloading ollama-linux-amd64.tar.zst … >>> Creating ollama systemd service... >>> Enabling and starting ollama service... Created symlink /etc/systemd/system/default.target.wants/ollama.service → /etc/systemd/system/ollama.service. ``` The GPU warning is expected. Ignore it. ## 2. Pull a small model and run a prompt Pick models from the [Ollama library](https://ollama.com/library) that fit in RAM with room to spare. We used [`qwen2.5:0.5b`](https://ollama.com/library/qwen2.5) and [`llama3.2:1b`](https://ollama.com/library/llama3.2): ```bash sbx exec $id "ollama pull qwen2.5:0.5b && ollama pull llama3.2:1b && ollama list" sbx exec $id "ollama run qwen2.5:0.5b 'Write a haiku about a sandbox.'" ``` ```text NAME ID SIZE MODIFIED llama3.2:1b baf6a787fdff 1.3 GB Less than a second ago qwen2.5:0.5b a8b0c5157701 397 MB 16 seconds ago In the sandbox, children play and laugh, playground laughter flows. ``` ## 3. Measure tokens per second Ollama's [generate API](https://docs.ollama.com/api/generate) returns `eval_count` (tokens generated) and `eval_duration` (nanoseconds). Tokens per second is `eval_count / eval_duration * 1e9`. Save this as `bench.sh` and run it in the sandbox with `sbx exec $id "$(cat bench.sh)"`: ```bash for m in qwen2.5:0.5b llama3.2:1b; do for i in 1 2 3; do curl -s http://127.0.0.1:11434/api/generate -d "{\"model\":\"$m\",\"prompt\":\"Explain what a microVM is in three sentences.\",\"stream\":false,\"options\":{\"num_predict\":128,\"temperature\":0}}" \ | python3 -c "import json,sys; r=json.load(sys.stdin); print('$m', 'run $i', 'load_s=%.2f'%(r['load_duration']/1e9), 'prompt_tok_s=%.1f'%(r['prompt_eval_count']/r['prompt_eval_duration']*1e9), 'eval_count=%d'%r['eval_count'], 'tok_s=%.1f'%(r['eval_count']/r['eval_duration']*1e9))" done done ``` On `default`: ```text qwen2.5:0.5b run 1 load_s=0.00 prompt_tok_s=575.4 eval_count=111 tok_s=56.3 qwen2.5:0.5b run 2 load_s=0.00 prompt_tok_s=1763.0 eval_count=111 tok_s=48.2 qwen2.5:0.5b run 3 load_s=0.00 prompt_tok_s=1458.7 eval_count=111 tok_s=57.4 llama3.2:1b run 1 load_s=0.00 prompt_tok_s=355.7 eval_count=107 tok_s=19.4 llama3.2:1b run 2 load_s=0.00 prompt_tok_s=648.7 eval_count=107 tok_s=16.3 llama3.2:1b run 3 load_s=0.00 prompt_tok_s=307.9 eval_count=107 tok_s=16.9 ``` A pass a minute earlier gave 28 to 30 tokens/s for `qwen2.5:0.5b` and 13 to 15 for `llama3.2:1b`. The first request for a model also paid `load_s=2.57` to load `llama3.2:1b` from disk. On `large`, with the same script: ```text qwen2.5:0.5b run 2 load_s=0.00 prompt_tok_s=1460.5 eval_count=111 tok_s=51.3 qwen2.5:0.5b run 3 load_s=0.00 prompt_tok_s=1352.5 eval_count=111 tok_s=52.3 llama3.2:1b run 2 load_s=0.00 prompt_tok_s=669.0 eval_count=107 tok_s=18.0 llama3.2:1b run 3 load_s=0.01 prompt_tok_s=527.1 eval_count=107 tok_s=22.4 … ``` Doubling the vCPUs didn't double the speed. With `"num_thread":8` in `options`, `llama3.2:1b` on `large` went from 17 to 19–22 tokens/s, against 17 with 4 threads. For one small model, `default` at $0.036 an hour is the better buy than `large` at twice that. Choose `large` when you need its 16 GB of RAM for a bigger model. ## 4. Expose the API on a private URL Ollama listens on `127.0.0.1:11434` by default. We tried both ways to reach it from outside. **`sbx host --localhost` doesn't work for Ollama.** The relay reaches the port, but Ollama rejects requests whose `Host` header isn't local. The proxy passed the request with the token, and Ollama answered `403`. Inside the sandbox, `curl -H "Host: -11434.box.sandboxer.com" http://127.0.0.1:11434/api/tags` also got `403`. **Binding to `0.0.0.0` works.** [Ollama's FAQ](https://docs.ollama.com/faq) sets `OLLAMA_HOST` on the systemd service. Then host the port without `--localhost`: ```bash sbx exec $id "sudo mkdir -p /etc/systemd/system/ollama.service.d && \ printf '[Service]\nEnvironment=\"OLLAMA_HOST=0.0.0.0:11434\"\n' | sudo tee /etc/systemd/system/ollama.service.d/override.conf && \ sudo systemctl daemon-reload && sudo systemctl restart ollama && ss -ltn | grep 11434" sbx host $id 11434 --title Ollama ``` ```text LISTEN 0 4096 *:11434 *:* https://maple-glossy-dune-11434.box.sandboxer.com?_token= ``` Before the override, the same URL returned `502` ("Nothing is answering on port 11434"). Ollama has no authentication of its own. The hosted URL's token is the lock, so keep the port private. Binding to `0.0.0.0` doesn't open the port to the internet: the sandbox firewall allows only SSH, and the hosting proxy reaches only ports you host. See [what can reach a sandbox](/guides/what-can-reach-a-sandbox-and-what-it-can-reach). ## 5. Call it from outside with curl Put the token in the query string. A `GET` with the token answers `302` and sets a cookie, so use `-L` and a cookie jar. A `POST` with the token goes straight through. ```bash BASE=https://maple-glossy-dune-11434.box.sandboxer.com TOKEN= curl -s -o /dev/null -w "%{http_code}\n" $BASE/api/tags # 401 curl -sL -c jar -b jar "$BASE/api/tags?_token=$TOKEN" # 200, the model list curl -s "$BASE/api/generate?_token=$TOKEN" \ -d '{"model":"qwen2.5:0.5b","prompt":"Reply with one word: ready?","stream":false}' ``` ```text 401 {"models":[{"name":"llama3.2:1b","model":"llama3.2:1b","modified_at":"2026-10-27T09:23:12.624999959Z","size":1321098329,… {"model":"qwen2.5:0.5b","created_at":"2026-10-27T09:25:35.546740041Z","response":"ready","done":true,"done_reason":"stop",…,"load_duration":794796193,…} ``` Ollama's [OpenAI-compatible endpoint](https://docs.ollama.com/api/openai-compatibility) works through the same URL: ```bash curl -s "$BASE/v1/chat/completions?_token=$TOKEN" -H "Content-Type: application/json" \ -d '{"model":"qwen2.5:0.5b","messages":[{"role":"user","content":"Name one prime number. Reply with the number only."}]}' ``` ```text {"id":"chatcmpl-685","object":"chat.completion",…,"choices":[{"index":0,"message":{"role":"assistant","content":"1"},"finish_reason":"stop"}],"usage":{"prompt_tokens":40,…,"completion_tokens":2,"total_tokens":42}} ``` That answer is wrong: 1 isn't prime. A 0.5B model is fast on a CPU and fine for classification, extraction or test fixtures. Don't rely on it for facts or reasoning. Five short requests (`num_predict: 8`) from our workstation to the sandbox in eu-central took 0.38 to 0.55 s each. ## 6. Stop, resume and cold boot ```bash sbx stop $id --wait sbx resume $id sbx exec $id "ollama ps; pgrep -f 'ollama serve'" ``` The resume took 1.4 s and reported `memoryRestored: true`. The server kept PID 947, `qwen2.5:0.5b` was still loaded, and the hosted URL answered at once with `load_duration` of 1.2 ms. The URL and token didn't change. A resume that boots from disk loses running processes. That happens when the resume lands on a different CPU type, or when you change size. We stopped again and ran `sbx resume $id --type large` (`memoryRestored: false`, uptime 0 min). Ollama came back because the install script enabled its systemd service, and our `OLLAMA_HOST` override came back with it. The first request through the URL returned `502` for a moment, then `200` in 1.4 s while the model loaded from disk. Later requests took 0.3 s. Retry a `502` right after a cold boot. Don't start Ollama with `ollama serve &` in a sandbox you plan to stop: nothing would restart it after a cold boot. ## FAQ ### Does a Sandboxer sandbox have a GPU? No. Sandboxes are CPU-only microVMs, and Ollama runs the whole model on the CPU. Use small models. In our test, 0.5B models ran at 28 to 59 tokens per second and 1B models at 13 to 22. Bigger models are slower. ### Which model size fits which sandbox? The model has to fit in RAM next to everything else. `llama3.2:1b` used 1.5 GB loaded in our test, out of 8 GB on `default`. For models above about 3B, use `large` (16 GB) and expect lower tokens per second. ### Why does `sbx host --localhost` return 403 for Ollama? Ollama checks the `Host` header when it listens on loopback and rejects names it doesn't know. Set `OLLAMA_HOST=0.0.0.0:11434` on the service and host the port without `--localhost`. ### Is my Ollama API open to the internet? Only through the hosted URL, and that URL is private by default. Requests without the token get `401` before they reach Ollama. Don't make the port `--public`, because Ollama has no login of its own. ### Does the model stay loaded while the sandbox is stopped? Nothing runs while it is stopped, and nothing is billed. After a memory-restoring resume, the model was still in memory. After a cold boot, Ollama loads it again on the first request. ## Related - [Run Open WebUI with Ollama in a sandbox](/guides/run-open-webui-with-ollama-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a cloud sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How Do I Run Open WebUI With Ollama in a Cloud Sandbox? Source: https://sandboxer.com/guides/run-open-webui-with-ollama-in-a-sandbox. Updated 2026-10-27. **Short answer:** Install Ollama in a default Sandboxer sandbox, pull a small model, then run the official Open WebUI Docker image with --network=host and OLLAMA_BASE_URL=http://127.0.0.1:11434. Host port 8080 with sbx host: the URL is private by default, chat streams through it, and your account and chats survive stop and resume. ## At a glance Everything below ran on one `default` sandbox (4 vCPU, 8 GB RAM, 50 GB disk) on October 27, 2026. CPU only: there is no GPU in a sandbox. Single runs, so treat timings as rough. | Step | What we saw | | - | - | | Pull `ghcr.io/open-webui/open-webui:main` | 4.56 GB image, 1 min 41 s | | First start to `/health` = `{"status":true}` | Under 50 s, including a one-time download of its embedding model | | Open WebUI version | `0.12.4` | | Hosted URL without the token | `401` | | First signup | Became `admin`. Signup then turned off: a second signup got `403` | | Streaming chat through the URL (`qwen2.5:0.5b`) | `text/event-stream`, first token after 1.7 s cold, 0.2 s with the model loaded | | Socket.IO WebSocket through the URL | `401` without the cookie, open frame with it | | `sbx stop --wait` + `sbx resume` | `memoryRestored: true`, same PIDs, chats kept | | Reboot (cold boot) | Ollama and Open WebUI came back on their own, chats and login kept | ## 1. Create a sandbox and install Ollama The image is 4.56 GB and Open WebUI loads an embedding model at start, so use `default` rather than `small`. Start the image pull in the background while Ollama installs. ```bash sbx new --name open-webui --type default id=sb_ws4ypogjyw # the id that sbx new printed sbx exec $id --detach "docker pull ghcr.io/open-webui/open-webui:main > pull.log 2>&1" sbx exec $id --timeout 600 "curl -fsSL https://ollama.com/install.sh | sh" sbx exec $id "ollama pull qwen2.5:0.5b && ollama list" ``` ```text >>> Creating ollama systemd service... >>> Enabling and starting ollama service... WARNING: Unable to detect NVIDIA/AMD GPU. Install lspci or lshw to automatically detect and install GPU dependencies. … NAME ID SIZE MODIFIED qwen2.5:0.5b a8b0c5157701 397 MB Less than a second ago ``` The GPU warning is expected: Ollama runs on the CPU. The install script ([Ollama for Linux](https://ollama.com/download/linux)) adds an enabled `ollama` systemd service that listens on `127.0.0.1:11434`. Model choice, CPU speed and the Ollama API are covered in [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox). This page is about the web UI. ## 2. Run Open WebUI and connect it to Ollama Open WebUI's [quick start](https://docs.openwebui.com/getting-started/quick-start/) uses `--add-host=host.docker.internal:host-gateway` to reach Ollama on the host. That only works if Ollama listens beyond loopback. Ollama here listens on `127.0.0.1` only, so use the host-network command from Open WebUI's own [connection troubleshooting page](https://docs.openwebui.com/troubleshooting/connection-error/). Ollama stays unreachable from outside, and the UI listens on port 8080. ```bash sbx exec $id "docker run -d --network=host \ -v open-webui:/app/backend/data \ -e OLLAMA_BASE_URL=http://127.0.0.1:11434 \ --name open-webui --restart unless-stopped \ ghcr.io/open-webui/open-webui:main" sbx exec $id --timeout 300 "until curl -sf localhost:8080/health >/dev/null; do sleep 1; done; \ curl -s localhost:8080/api/version; ss -ltn | grep -E '8080|11434'" ``` ```text {"version":"0.12.4","deployment_id":""} LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:* LISTEN 0 2048 0.0.0.0:8080 0.0.0.0:* ``` Two flags matter later. `-v open-webui:/app/backend/data` keeps users, chats and settings in a Docker volume (about 1.1 GB after first start, mostly cached models). `--restart unless-stopped` brings the container back after a cold boot. The first start downloads a sentence-transformers model from Hugging Face. The sandbox's internet access is enough. Don't call `/health` failed if it takes 30 to 50 seconds. ## 3. Give it a private HTTPS URL ```bash sbx host $id 8080 --title "Open WebUI" ``` ```text https://helpful-joyful-eel-8080.box.sandboxer.com?_token= ``` A hosted port is private by default. What the proxy returned: | Request | Status | | - | - | | `https://helpful-joyful-eel-8080.box.sandboxer.com/` (no token) | `401`, and Open WebUI never sees it | | The full URL with `?_token=` | `302` to `/`, sets the `__sbx_auth` cookie (30 days) | | `/` with the cookie | `200`, `Open WebUI` | So there are two locks. The Sandboxer token keeps strangers off the page, and Open WebUI's own login sits behind it. Open the full token URL in your browser once and the cookie does the rest. See [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) for public URLs, `--localhost` and how to revoke a link. ## 4. Create the admin account first Open the URL and sign up. The first account becomes the administrator. We signed up through the API from Node 24 (the `__sbx_auth` cookie in the `Cookie` header) to record the responses: ```javascript const r = await fetch(`${base}/api/v1/auths/signup`, { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Admin", email: "admin@example.com", password: "…" }), }); console.log(r.status, (await r.json()).role); ``` | Signup | Result | | - | - | | `admin@example.com` (first) | `200`, `"role": "admin"` | | `second@example.com` | `403` "You do not have permission to access this resource" | After the first account, `/api/config` reported `"enable_signup": false`. Sign up yourself before you share the link with anyone. Whoever holds the token URL and gets to the signup form first owns the instance. The admin can turn signup back on or add users in the admin panel. ## 5. Chat: streaming and WebSockets through the proxy Open WebUI streams replies over Server-Sent Events and uses a Socket.IO WebSocket at `/ws/socket.io/` for live updates. Both went through the hosted URL. We called the OpenAI-style endpoint with the admin's JWT and the `__sbx_auth` cookie from Node 24: ```javascript const r = await fetch(`${base}/api/chat/completions`, { method: "POST", headers: { Cookie: cookie, Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: "qwen2.5:0.5b", stream: true, messages: [{ role: "user", content: "In two sentences, what is a microVM?" }], }), }); // read r.body line by line and time the first "data:" chunk with content ``` ```text chat: 200 text/event-stream first token after 1705 ms, 29 chunks in 2447 ms models: 200 qwen2.5:0.5b ``` The 1.7 seconds includes Ollama loading the model. With the model already loaded, the first token arrived after 198 ms. The answers from a 0.5B model are short and often wrong. Pick a bigger model if you have the RAM; the Ollama guide compares sizes. The WebSocket handshake to `wss://…-8080.box.sandboxer.com/ws/socket.io/?EIO=4&transport=websocket` returned `401` without the cookie. With it, the first frame was the Socket.IO open packet, `0{"sid":"m6dDZJIjrq5hoL_MAAAA",…`. In a browser you don't need to do anything: the page sends the cookie on its own WebSocket. ## 6. Stop, resume and reboot We saved a chat titled "Before stop", noted the PIDs, then stopped and resumed. ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker inspect -f 'pid={{.State.Pid}}' open-webui; systemctl show -p MainPID ollama" ``` ```text ready memoryRestored=true # from the resume JSON pid=954 MainPID=795 ``` Same PIDs as before the stop. The URL, the token and the 30-day cookie still worked, `GET /api/v1/chats/` listed "Before stop", and the model was still loaded (first token after 198 ms). Use `--wait` before resuming: a resume during the snapshot is refused. Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk with `memoryRestored: false`. To test that path, we rebooted the VM with `sudo systemctl reboot`: ```text 20.64 59.62 # /proc/uptime: a fresh boot 24.69 72.05 # /health answered 4 s later pid=617 active # ollama.service qwen2.5:0.5b a8b0c5157701 397 MB 2 minutes ago ``` Ollama came back as an enabled systemd service. Open WebUI came back because of `--restart unless-stopped`. The admin could sign in, the "Before stop" chat was there, and the first token took 1.8 s while the model loaded again. A container started without a restart policy would stay down after a cold boot. ## When this setup fits | Situation | Fits? | | - | - | | Try Open WebUI and small models without installing anything locally | Yes | | A private chat UI for you and a few people you send the link to | Yes, with signup closed after the admin | | Large models or fast replies | Not on CPU. Bigger models reply more slowly and need more RAM; see the Ollama guide | | A public service for strangers | Not with `--public`: Open WebUI's login would be the only gate | ## FAQ ### Why not use the quick start's port mapping and host.docker.internal? That works only if Ollama listens on an address the container can reach. The Ollama service here listens on `127.0.0.1:11434`, so `--network=host` with `OLLAMA_BASE_URL=http://127.0.0.1:11434` is the simplest fix. It is the host-network command from Open WebUI's own troubleshooting page, with `--restart unless-stopped` in place of `--restart always`. ### Do I need a GPU? No. Everything on this page ran on CPU. Small models like `qwen2.5:0.5b` reply in a second or two. Sandboxes have no GPU, so bigger models are slower. ### Who can open the URL? Only people with the full token URL or the cookie it sets. Everyone else gets `401` from the proxy before reaching Open WebUI. Inside, they still need an Open WebUI account. ### Do my chats survive a stop? Yes. They live in the `open-webui` Docker volume on the sandbox disk. They were kept across a memory-restoring resume and across a reboot. ### Does the sandbox stop on its own? Yes, after one hour by default and after a period of inactivity. Both stops keep memory when possible. Change them with `sbx extend $id --hours 12` or `sbx idle $id 0`. ## Related - [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Hosting](/docs/hosting) --- # How Do I Run llama.cpp's llama-server on CPU in a Cloud Sandbox? Source: https://sandboxer.com/guides/run-llama-cpp-server-on-cpu-in-a-sandbox. Updated 2026-10-27. **Short answer:** Download the prebuilt Linux x64 build from llama.cpp's GitHub releases, fetch a GGUF model from Hugging Face with curl, and run llama-server on 0.0.0.0 as a systemd service. Then sbx host the port for a private HTTPS URL that any OpenAI client can call. A 0.5B model generated 35 to 48 tokens per second on default. ## At a glance We ran everything below on prod on October 27, 2026: a `default` sandbox (4 vCPU, 8 GB RAM), later resumed as `large` (8 vCPU, 16 GB RAM). llama.cpp build `b11925`. **CPU only:** a sandbox has no GPU. We used the plain `ubuntu-x64` build, which picked its `zen4` CPU backend on its own. | Step | What we saw | | - | - | | Download and unpack the release tarball | Under 1 s, no compiler needed | | Download two GGUF files from Hugging Face | 469 MB in 7 s, 1.1 GB in 12 s, no token | | `llama-bench`, Qwen2.5 0.5B Q4_K_M, `default`, 4 threads | 545 tokens/s prompt, 47 tokens/s generation | | `llama-bench`, Qwen2.5 1.5B Q4_K_M, `default`, 4 threads | 236 tokens/s prompt, 23 tokens/s generation | | Same two models on `large`, 8 threads | 0.5B: 525 / 60 tokens/s. 1.5B: 431 / 23 tokens/s | | `llama-server` timings, 0.5B, real chat request | 35 to 48 tokens/s on `default`, 57 to 61 on `large` | | Hosted URL, no token / token but no API key / both | `401` / `401` (from llama-server) / `200` | | Short request from our workstation through the URL | 0.35 to 0.51 s round trip | | `sbx stop --wait` + `sbx resume` | `memoryRestored: true`, same PID, URL answered at once | | Resume as `large` (cold boot) | systemd started the server again; first request `502` (nothing listening yet), the retry `200` | If you want a one-line install and a model library, [run Ollama instead](/guides/run-ollama-on-cpu-in-a-cloud-sandbox). Ollama is built on llama.cpp. Running llama.cpp directly gives you the exact build, any GGUF file on Hugging Face, `llama-bench`, and per-request `timings` in every response. ## 1. Download llama.cpp The [llama.cpp releases page](https://github.com/ggml-org/llama.cpp/releases) publishes a prebuilt CPU build for Linux on every build number. Pin one so a rebuild gives you the same binary: ```bash sbx new --name llama --type default id=sb_pgchnyueil # the id that sbx new printed sbx exec $id "curl -fsSL -o llama.tar.gz https://github.com/ggml-org/llama.cpp/releases/download/b11925/llama-b11925-bin-ubuntu-x64.tar.gz \ && mkdir -p llama && tar xzf llama.tar.gz -C llama --strip-components=1 && ./llama/llama-server --version" ``` ```text version: 0.7.0-dev (build 11925, commit fae9ac561) built with GNU 11.4.0 for Linux x86_64 ``` The tarball holds `llama-server`, `llama-cli`, `llama-bench` and one CPU backend library per CPU family (`libggml-cpu-haswell.so`, `libggml-cpu-zen4.so` and so on). At startup it loads the best one for the CPU it finds. Don't pick a `cuda`, `rocm` or `vulkan` asset: a sandbox has no GPU. To build from source instead, follow the repo's [build guide](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md). ## 2. Download a GGUF model `llama-server` reads one `.gguf` file. Public repos on Hugging Face need no token. We used Qwen's official [0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF) and [1.5B](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF) instruct models at `Q4_K_M`: ```bash sbx exec $id "mkdir -p models && cd models \ && curl -fsSL -O https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf \ && curl -fsSL -O https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf \ && ls -lh" ``` ```text -rw-r--r-- 1 user user 469M Oct 27 10:15 qwen2.5-0.5b-instruct-q4_k_m.gguf -rw-r--r-- 1 user user 1.1G Oct 27 10:15 qwen2.5-1.5b-instruct-q4_k_m.gguf ``` The URL pattern is `https://huggingface.co//resolve/main/`. `llama-server -hf /:` can also download for you, as the [server README](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) describes, but an explicit file path is easier to put in a systemd unit. ## 3. Measure tokens per second and pick a thread count `llama-bench` measures prompt processing (`pp512`, 512 prompt tokens) and generation (`tg128`, 128 new tokens) separately. `-t 1,2,4` sweeps the thread count: ```bash sbx exec $id --timeout 600 "./llama/llama-bench -m models/qwen2.5-0.5b-instruct-q4_k_m.gguf \ -m models/qwen2.5-1.5b-instruct-q4_k_m.gguf -t 1,2,4 -p 512 -n 128 -r 3" ``` ```text | model | size | threads | test | t/s | | ------------------------ | ---------: | ------: | ----: | -------------: | | qwen2 1B Q4_K - Medium | 462.96 MiB | 1 | pp512 | 153.85 ± 15.70 | | qwen2 1B Q4_K - Medium | 462.96 MiB | 1 | tg128 | 28.42 ± 0.44 | | qwen2 1B Q4_K - Medium | 462.96 MiB | 2 | pp512 | 318.03 ± 2.46 | | qwen2 1B Q4_K - Medium | 462.96 MiB | 2 | tg128 | 35.45 ± 1.12 | | qwen2 1B Q4_K - Medium | 462.96 MiB | 4 | pp512 | 544.90 ± 38.33 | | qwen2 1B Q4_K - Medium | 462.96 MiB | 4 | tg128 | 46.69 ± 0.87 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 1 | pp512 | 67.64 ± 0.61 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 1 | tg128 | 15.36 ± 0.39 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 2 | pp512 | 111.38 ± 14.43 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 2 | tg128 | 20.72 ± 1.00 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 4 | pp512 | 236.24 ± 13.00 | | qwen2 1.5B Q4_K - Medium | 1.04 GiB | 4 | tg128 | 22.64 ± 2.00 | ``` (Columns trimmed. llama-bench labels the 0.5B model "1B"; it is the 469 MB file.) Later we resumed the sandbox as `large` and ran `-t 2,4,8`: | Model | Test | 4 threads | 8 threads | | - | - | - | - | | Qwen2.5 0.5B | pp512 | 403 | 525 | | Qwen2.5 0.5B | tg128 | 47 | 60 | | Qwen2.5 1.5B | pp512 | 242 | 431 | | Qwen2.5 1.5B | tg128 | 22 | 23 | What the numbers say: - **Use one thread per vCPU.** On both sizes, the most threads gave the most speed. We didn't test more threads than vCPUs. - **Prompt speed scales with cores. Generation speed mostly doesn't.** Doubling to 8 vCPUs nearly doubled prompt speed for the 1.5B model, but its generation moved from 22 to 23 tokens/s. Generation is usually limited by memory bandwidth, not core count. - **`large` pays off for long prompts**, such as RAG with big retrieved contexts, or for models that need its 16 GB of RAM. For short prompts and long answers, `default` at $0.036 an hour gives almost the same generation speed for half the price. `llama-server` takes `-t` for generation threads and `-tb` for prompt processing threads. We left both unset, and the server's own timings, below, were in line with the full-vCPU `llama-bench` rows. ## 4. Run llama-server as a systemd service A systemd service starts again after a cold boot. A process you start by hand doesn't. Bind to `0.0.0.0` so the hosting proxy can reach it, and give it an API key of its own: ```bash sbx exec $id "umask 077 && openssl rand -hex 24 > ~/.llama-api-key" sbx exec $id "sudo tee /etc/systemd/system/llama-server.service >/dev/null <<'EOF' [Unit] Description=llama.cpp server After=network-online.target [Service] User=user WorkingDirectory=/home/user ExecStart=/home/user/llama/llama-server -m /home/user/models/qwen2.5-0.5b-instruct-q4_k_m.gguf --alias qwen2.5-0.5b --host 0.0.0.0 --port 8080 -c 4096 --api-key-file /home/user/.llama-api-key --metrics Restart=on-failure [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload && sudo systemctl enable --now llama-server" ``` - `--alias` sets the model name clients send and `/v1/models` lists. Without it, the name is the file path. - `--api-key-file` makes every endpoint except `/health` require `Authorization: Bearer `. Inside the sandbox, a request without it got `401`. - Set `--host` and `--port` yourself. The server listens on `127.0.0.1` by default, which the hosting proxy can't reach. ## 5. Read prompt and generation speed from each response Every `/v1/chat/completions` response from `llama-server` includes a `timings` object. `prompt_per_second` and `predicted_per_second` are the two speeds. We ran this script in the sandbox (`sbx exec $id "$(cat bench.sh)"`): ```bash KEY=$(cat ~/.llama-api-key) for i in 1 2 3; do curl -s http://127.0.0.1:8080/v1/chat/completions -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"Explain what a microVM is in three sentences."}],"max_tokens":128,"temperature":0,"cache_prompt":false}' \ | python3 -c "import json,sys; t=json.load(sys.stdin)['timings']; print('run $i prompt_n=%d prompt_tok_s=%.1f predicted_n=%d gen_tok_s=%.1f' % (t['prompt_n'], t['prompt_per_second'], t['predicted_n'], t['predicted_per_second']))" done ``` On `default`: ```text run 1 prompt_n=40 prompt_tok_s=178.7 predicted_n=87 gen_tok_s=35.2 run 2 prompt_n=40 prompt_tok_s=302.5 predicted_n=87 gen_tok_s=36.9 run 3 prompt_n=40 prompt_tok_s=307.4 predicted_n=87 gen_tok_s=42.4 ``` On `large`: ```text run 1 prompt_n=40 prompt_tok_s=233.6 predicted_n=87 gen_tok_s=61.3 run 2 prompt_n=40 prompt_tok_s=371.1 predicted_n=87 gen_tok_s=57.2 run 3 prompt_n=40 prompt_tok_s=420.8 predicted_n=87 gen_tok_s=60.7 ``` Keep `"cache_prompt":false` when you benchmark. Our first pass left the default on, and runs 2 and 3 showed `prompt_n=1`: the server reused the cached prompt and processed one token, which makes prompt speed meaningless. In production, leave the cache on. A 40-token prompt is too short for a stable prompt speed. Use `llama-bench` for that number. ## 6. Give it a private HTTPS URL ```bash sbx host $id 8080 --title llama-server ``` ```text https://broad-coastal-cove-8080.box.sandboxer.com?_token= ``` Now there are two locks. The hosted URL's `_token` keeps everyone else from reaching the server at all. The llama-server API key is checked by the server itself. From our workstation: ```bash BASE=https://broad-coastal-cove-8080.box.sandboxer.com TOKEN= # from sbx host LLAMA_KEY= # the contents of ~/.llama-api-key curl -s -o /dev/null -w "%{http_code}\n" $BASE/health # 401 from the proxy curl -sL -c jar -b jar "$BASE/v1/models?_token=$TOKEN" # 401 from llama-server curl -s "$BASE/v1/chat/completions?_token=$TOKEN" -H "Authorization: Bearer $LLAMA_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"qwen2.5-0.5b","messages":[{"role":"user","content":"Name the capital of France in one word."}],"max_tokens":16,"temperature":0}' ``` ```text 401 {"error":{"message":"Invalid API Key","type":"authentication_error","code":401}} {"choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"Paris."}}],…,"usage":{"completion_tokens":3,"prompt_tokens":38,"total_tokens":41,…},"timings":{…,"prompt_per_second":213.2,…,"predicted_per_second":52.6}} ``` A `GET` with `?_token=` answers `302` and sets a cookie, so use `-L` and a cookie jar for `GET` requests. A `POST` with the token goes straight through. Five short requests (`max_tokens: 8`) from our workstation to the sandbox in eu-central took 0.35 to 0.51 s each. For more about private URLs, see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). ## 7. Call it with the openai Python client Point `base_url` at the hosted URL plus `/v1`. Put the llama-server key in `api_key`, which the client sends as the `Authorization` header. Pass the hosted URL's token with `default_query`, so the client adds `?_token=` to every request. Don't paste the token into `base_url`: the client appends paths to it. ```python import os, time from openai import OpenAI client = OpenAI( base_url="https://broad-coastal-cove-8080.box.sandboxer.com/v1", api_key=os.environ["LLAMA_API_KEY"], # llama-server's --api-key default_query={"_token": os.environ["SBX_HOST_TOKEN"]}, # the hosted URL's token ) print([m.id for m in client.models.list()]) t = time.time() r = client.chat.completions.create( model="qwen2.5-0.5b", messages=[{"role": "user", "content": "Write one sentence about microVMs."}], max_tokens=64, temperature=0, ) print(r.choices[0].message.content) print(r.usage.prompt_tokens, r.usage.completion_tokens, "%.2fs" % (time.time() - t)) stream = client.chat.completions.create( model="qwen2.5-0.5b", messages=[{"role": "user", "content": "Count from 1 to 5."}], max_tokens=32, stream=True, ) for chunk in stream: if chunk.choices and chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True) print() ``` ```text ['qwen2.5-0.5b'] MicroVMs are small, lightweight virtual machines designed to run on low-end hardware, offering a more efficient and cost-effective alternative to traditional virtualization solutions. 37 32 1.02s Sure, I can count from 1 to 5 for you. 1. 1 2. 2 3. 3 4. ``` We ran this with `openai` 3.39.0. Streaming works through the hosted URL. The answer about microVMs is loose, and the count stopped at `max_tokens`. A 0.5B model is fast on a CPU and fine for classification, extraction or test fixtures. Don't rely on it for facts. ## 8. Stop, resume and cold boot ```bash sbx stop $id --wait sbx resume $id ``` The resume took 1.4 s and reported `memoryRestored: true`. `llama-server` kept its PID (750), the model stayed in memory, and the hosted URL answered at once with the same token. Then we stopped again and ran `sbx resume $id --type large`. A size change boots from disk, so the resume reported `memoryRestored: false` with `resumeReason: "resized"`. The systemd unit started `llama-server` again with a new PID. The first request through the URL got `502`: nothing was answering on port 8080 yet, because `llama-server` was still starting and loading the model. The retry a moment later got `200`. After a cold boot, retry a `502` for a few seconds until the server is up. The same cold boot happens when a resume lands on a machine with a different CPU type. ## FAQ ### Does a Sandboxer sandbox have a GPU? No. Sandboxes are CPU-only microVMs. Use the plain `ubuntu-x64` llama.cpp build, and pick small quantized models. In our test, a 0.5B model generated 35 to 61 tokens per second and a 1.5B model about 22. ### What thread count should I use? One thread per vCPU: 4 on `default`, 8 on `large`. In our sweep, more threads were faster on both sizes. Extra cores help prompt processing far more than generation. ### Do I need a Hugging Face token to download GGUF files? Not for public repos. A plain `curl` of the `resolve/main` URL worked for Qwen's GGUF repos, with no account. ### Is the llama-server API open to the internet? Only through the hosted URL, which is private by default. Requests without the URL's token get `401` from the proxy before they reach llama-server. Its own `--api-key` is a second lock for anyone you share the URL with. ### Which model sizes fit? The GGUF file has to fit in RAM with room for the context. A 1.1 GB file is easy on `default` (8 GB). Use `large` (16 GB) for bigger models. Bigger models generate more slowly: the 1.5B model ran at about half the speed of the 0.5B. ## Related - [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Machines and sizes](/docs/machines) - [Long-running tasks](/docs/long-running) --- # How to Transcribe Audio with whisper.cpp in a Cloud Sandbox Source: https://sandboxer.com/guides/transcribe-audio-with-whisper-cpp-in-a-sandbox. Updated 2026-10-27. **Short answer:** Clone whisper.cpp, build it with cmake, fetch base.en or small.en with its download script, and run whisper-cli on 16 kHz WAV. On a 4-vCPU default sandbox, base.en ran 13x faster than realtime and small.en 5x. whisper-server behind a private sbx host URL accepts file POSTs. ## At a glance Everything below ran on prod on 2026-10-27, CPU only, with [whisper.cpp](https://github.com/ggml-org/whisper.cpp) v1.9.5 (commit `231638a`). The test clip is a 198.7-second US government speech from Wikimedia Commons, which is public domain. | Step | Command | What we saw | | - | - | - | | Build | `cmake -B build && cmake --build build -j --config Release` | 1 min 38 s on `default`, no extra packages | | Get a model | `sh ./models/download-ggml-model.sh small.en` | 488 MB in 6 s (`base.en`: 148 MB in 2 s) | | Transcribe the 11 s JFK sample | `whisper-cli -f samples/jfk.wav` | 1.3 s total with `base.en` | | Transcribe 198.7 s of speech | `whisper-cli -m … -t 4 -f gb1.wav` | 15.3 s (`base.en`), 40.6 s (`small.en`) | | Serve it over HTTP | `whisper-server … --host 0.0.0.0 --convert` + `sbx host $id 8080` | `401` without the token, `200` with it | | POST an MP3 from a laptop | `curl -F file=@jfk.mp3 "$URL/inference?_token=…"` | Transcript back in 3.8–4.0 s | ## 1. Create a sandbox and build whisper.cpp Every sandbox already has `git`, `cmake`, `gcc`, `make`, `curl` and `ffmpeg`, so the build needs no `apt` step. A `default` sandbox (4 vCPU, 8 GB, $0.036/hour) is enough for `base.en` and `small.en`. ```bash id=$(sbx new --type default --name aeo-whisper-cpp-1 --json | jq -r 'select(.event == "ready") | .id') sbx exec $id --timeout 600 ' git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git cd whisper.cpp time (cmake -B build && cmake --build build -j --config Release)' ``` ```text -- Found OpenMP: TRUE (found version "4.5") -- Adding CPU backend variant ggml-cpu: -march=native … [100%] Built target whisper-server real 1m38.416s ``` These are the build steps from the project's [README quick start](https://github.com/ggml-org/whisper.cpp#quick-start). The build is longer than a minute, so pass `--timeout`. For builds that run longer than synchronous commands allow, see [how to run a long job](/guides/run-a-long-job-in-a-cloud-sandbox). ## 2. Download a model and transcribe the JFK sample The repo ships `samples/jfk.wav`, the 11-second "ask not" line from Kennedy's 1961 inaugural address. ```bash sbx exec $id --timeout 600 ' cd whisper.cpp sh ./models/download-ggml-model.sh base.en sh ./models/download-ggml-model.sh small.en ./build/bin/whisper-cli -m models/ggml-base.en.bin -f samples/jfk.wav' ``` ```text system_info: n_threads = 4 / 4 | … CPU : SSE3 = 1 | SSSE3 = 1 | AVX = 1 | AVX2 = 1 | F16C = 1 | FMA = 1 | BMI2 = 1 | AVX512 = 1 | … OPENMP = 1 | main: processing 'samples/jfk.wav' (176000 samples, 11.0 sec), 4 threads, 1 processors, 5 beams + best of 5, lang = en, task = transcribe, timestamps = 1 ... [00:00:00.000 --> 00:00:11.000] And so my fellow Americans, ask not what your country can do for you, ask what you can do for your country. whisper_print_timings: load time = 108.92 ms whisper_print_timings: encode time = 702.79 ms / 1 runs ( 702.79 ms per run) whisper_print_timings: total time = 1307.67 ms ``` `whisper-cli` reads 16-bit, 16 kHz mono WAV. Convert anything else with `ffmpeg` first, as the README shows. ## 3. Transcribe a longer clip We used `gb1` from the repo's own `make samples` list: President George W. Bush's address on the loss of Space Shuttle Columbia, a public-domain recording on [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:George_W_Bush_Columbia_FINAL.ogg). ```bash sbx exec $id --timeout 300 ' cd whisper.cpp curl -sL -A "whisper-test/1.0" -o samples/gb1.ogg \ https://upload.wikimedia.org/wikipedia/commons/1/1f/George_W_Bush_Columbia_FINAL.ogg ffmpeg -loglevel error -y -i samples/gb1.ogg -ar 16000 -ac 1 -c:a pcm_s16le samples/gb1.wav ffprobe -v error -show_entries format=duration -of csv=p=0 samples/gb1.wav' ``` ```text 198.734375 ``` Our first `wget` of the file exited with code 8 (a server error) and left an empty file. A retry with `curl` and a `User-Agent` header worked. Wikimedia asks scripts to send one; see its [User-Agent policy](https://meta.wikimedia.org/wiki/User-Agent_policy). Here is the start of the real `small.en` transcript (`-otxt` writes it to a `.txt` file): ```text My fellow Americans, this day has brought terrible news and great sadness to our country. At 9 o'clock this morning, Mission Control in Houston lost contact with our space shuttle Columbia. A short time later, debris was seen falling from the skies above Texas. The Columbia's lost. There are no survivors. On board was a crew of seven. Colonel Rick Husband, Lieutenant Colonel Michael Anderson, Commander Laurel Clark, Captain David Brown, Commander William McCool, Dr. Kultana Shavla, and Ilan Ramon, a colonel in the Israeli Air Force. … May God bless the grieving families and may God continue to bless America. ``` The model choice shows in the text. `base.en` wrote "One board was a crew of seven", "Elon Ramon", "Our entire nation agrees with you" and "Those of their courage, endearing, and idealism". `small.en` got all four right. Both misheard Kalpana Chawla's name. whisper.cpp has a `--prompt` option for an initial prompt, which you could seed with names. We didn't test it here. ## 4. Measure the realtime factor The realtime factor (RTF) is processing time divided by audio length. Below 1 is faster than realtime. This script times `whisper-cli` end to end, including model load, for each model and thread count: ```bash title="~/bench.sh" cd ~/whisper.cpp dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 samples/gb1.wav) for m in base.en small.en; do for t in $THREADS; do s=$(date +%s.%N) ./build/bin/whisper-cli -m models/ggml-$m.bin -t $t -f samples/gb1.wav -otxt -of /tmp/gb1-$m-t$t >/dev/null 2>&1 e=$(date +%s.%N) awk -v m=$m -v t=$t -v s=$s -v e=$e -v d=$dur 'BEGIN{w=e-s; printf "%-9s threads=%d wall=%6.1fs RTF=%.3f\n", m, t, w, w/d}' done done ``` ```bash sbx exec $id --timeout 600 'THREADS="1 2 4" bash ~/bench.sh' ``` Then we stopped the sandbox, resumed it as `large` (8 vCPU, 16 GB, $0.072/hour) and ran `THREADS="4 8" bash ~/bench.sh`. All runs use the CLI default of 5 beams. | Size | Model | Threads | Wall time for 198.7 s | RTF | Speed vs realtime | | - | - | - | - | - | - | | `default` | `base.en` | 1 | 42.5 s | 0.214 | 4.7x | | `default` | `base.en` | 2 | 22.2 s | 0.112 | 9.0x | | `default` | `base.en` | 4 | 15.3 s | 0.077 | 13.0x | | `default` | `small.en` | 1 | 142.9 s | 0.719 | 1.4x | | `default` | `small.en` | 2 | 64.7 s | 0.326 | 3.1x | | `default` | `small.en` | 4 | 40.6 s | 0.204 | 4.9x | | `large` | `base.en` | 4 | 13.4 s | 0.068 | 14.8x | | `large` | `base.en` | 8 | 8.9 s | 0.045 | 22.3x | | `large` | `small.en` | 4 | 38.3 s | 0.193 | 5.2x | | `large` | `small.en` | 8 | 25.2 s | 0.127 | 7.9x | What the numbers say: - **Set `-t` to the vCPU count.** The default is 4 threads, so on `large` you must pass `-t 8` to use the extra cores. - **`large` costs twice as much per hour but was not twice as fast.** `small.en` went from 40.6 s to 25.2 s (1.6x). Pick `large` when you need lower latency per file. Pick `default` for the lowest cost per audio hour. - **Short clips have a floor.** Whisper encodes 30-second windows, so the 11-second JFK clip took as long to encode as 30 seconds would: 0.75 s (`base.en`) and 2.25 s (`small.en`) at 4 threads. Estimated cost per hour of audio, from the RTFs above and [per-second billing](/docs/billing): `small.en` on `default` at 4 threads takes about 12 minutes, roughly $0.007. `base.en` takes about 4.6 minutes, roughly $0.003. These are extrapolations from one clip, not a bulk run. ## 5. Serve it over HTTP on a private URL whisper.cpp includes [`whisper-server`](https://github.com/ggml-org/whisper.cpp/tree/master/examples/server), a small HTTP server with an `/inference` endpoint. Its README warns that it accepts file uploads and runs `ffmpeg` on them, and says to run it in a sandbox without admin rights. Here it runs as the unprivileged `user` account inside its own VM. Run it as a systemd service so it comes back after a cold boot: ```bash sbx exec $id ' sudo tee /etc/systemd/system/whisper.service >/dev/null <<"UNIT" [Unit] Description=whisper.cpp server After=network-online.target [Service] User=user WorkingDirectory=/home/user/whisper.cpp ExecStart=/home/user/whisper.cpp/build/bin/whisper-server -m models/ggml-small.en.bin -t 4 --host 0.0.0.0 --port 8080 --convert Restart=always [Install] WantedBy=multi-user.target UNIT sudo systemctl daemon-reload && sudo systemctl enable --now whisper' ``` `--host 0.0.0.0` lets the hosting proxy reach it. `--convert` makes the server run `ffmpeg` on uploads, so you can send MP3 or OGG, not only WAV. Now give port 8080 a URL: ```bash sbx host $id 8080 --title whisper ``` ```json {"ok":true,"type":"port.hosted","sandboxId":"sb_3spo3t1kbc","port":8080,"url":"https://patient-fearless-poplar-8080.box.sandboxer.com?_token="} ``` The URL is private. From a laptop, without the token: ```bash curl -s -o /dev/null -w "%{http_code}\n" -F file=@jfk.mp3 https://patient-fearless-poplar-8080.box.sandboxer.com/inference ``` ```text 401 ``` With the token in the query string, the POST goes through: ```bash curl -s -w "\n%{http_code} total=%{time_total}s\n" -F file=@jfk.mp3 -F response_format=text \ "https://patient-fearless-poplar-8080.box.sandboxer.com/inference?_token=$TOKEN" ``` ```text And so, my fellow Americans, ask not what your country can do for you. Ask what you can do for your country. 200 total=3.810330s ``` Three runs took 3.81, 4.01 and 3.99 seconds. The same request sent from inside the sandbox took about 3.3 seconds, so most of the time is the `small.en` encode, not the network. `response_format=verbose_json` returns segments with start and end times and per-word probabilities. A POST to the token URL got `200` directly. A GET gets a `302` that sets the `__sbx_auth` cookie, which also works for later requests. The [hosting guide](/guides/give-a-sandbox-port-an-https-url) covers tokens and cookies in full. A longer file works too. Uploading the 1.6 MB `gb1.ogg` to the same URL on `large` returned the full transcript in 32.3 seconds. The server uses greedy decoding by default, not the CLI's 5 beams. ## What survives stop and resume We ran `sbx stop $id --wait`, then `sbx resume $id --type large`. The resume took 3.4 seconds and reported `memoryRestored: false`, because a resize boots from disk. After the boot: - The clone, the build and both models were still on disk. - The `whisper` service was `active` again because it is an enabled systemd unit. A server started by hand would not have come back. - The hosted URL and its token were unchanged. The first POST after the resume returned `200` in 3.3 seconds. The cmake output above shows the CPU backend is built with `-march=native`. Our resize landed on the same CPU model, so the binary ran unchanged. We did not test a resume onto a different CPU type. ## FAQ ### Does whisper.cpp need a GPU? No. Everything on this page ran on CPU, and Sandboxer [sizes](/docs/machines) have no GPU. The build detected AVX2 and AVX-512 and used OpenMP across the vCPUs. ### Which model should I use on a CPU sandbox? Start with `small.en` for English. On `default` it ran at 4.9x realtime and got names and phrases right that `base.en` missed. Use `base.en` when speed matters more than accuracy. Drop the `.en` for other languages. ### Why does a 10-second clip take as long as a 30-second one? Whisper's encoder always works on a 30-second window. Short clips pay that full encode cost, so their realtime factor looks worse than a long file's. ### How do I send audio from my laptop? POST it to the hosted URL with `curl -F file=@audio.mp3`, as shown above, with `--convert` on the server. To copy files into the sandbox instead, see [moving files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox). ## Related - [How do I give a port in a cloud sandbox an HTTPS URL?](/guides/give-a-sandbox-port-an-https-url) - [How to run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) - [Machines and sizes](/docs/machines) - [Hosting](/docs/hosting) --- # How to Run n8n in a Cloud Sandbox With a Working Webhook URL Source: https://sandboxer.com/guides/run-n8n-in-a-cloud-sandbox. Updated 2026-10-27. **Short answer:** Run n8n's Docker image in a Sandboxer sandbox with a volume and --restart unless-stopped, set N8N_HOST, N8N_PROTOCOL and N8N_WEBHOOK_URL to the hosted address, then run sbx host 5678. The editor and webhooks answer on https://-5678.box.sandboxer.com, private by default; go public only for callers that can't send a token. ## At a glance Everything here ran on one `small` sandbox (2 vCPU, 4 GB) on October 27, 2026, with n8n 2.45.4. Single runs, not benchmarks. | Step | What we saw | | - | - | | `sbx new --type small` | Ready in 3.2 seconds | | n8n start with an existing volume | "Editor is now accessible" after 17.4 seconds | | Memory at idle | 731 MiB | | Hosted URL, no token | `401` from the Sandboxer proxy | | Hosted URL with the token | `302`, then the n8n editor (`n8n.io - Workflow Automation`) | | Webhook call through the hosted URL | Median 225 ms over 20 calls from outside (each a new TLS connection), 64 ms from inside the sandbox | | `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, same n8n PID, webhook answered 223 ms after resume returned | | Cold boot (resume as `default`) | `memoryRestored: false`, Docker restarted n8n, webhook answered after 16.9 seconds, workflow and owner account kept | ## Start n8n with the hosted address n8n's [Docker install docs](https://docs.n8n.io/hosting/installation/docker/) start the container with a `n8n_data` volume mounted at `/home/node/.n8n`. We used the same image and variables, swapped `-it --rm` for `-d --restart unless-stopped` so it survives reboots, and added the URL settings. A sandbox's hosted URL is `https://-.box.sandboxer.com`, and `sbx new` prints the subdomain. Ours was `silent-stormy-mint`, so n8n's address is `https://silent-stormy-mint-5678.box.sandboxer.com`. Set it before you start n8n: ```bash sbx new --name n8n-demo --type small id=sb_68ufa2y5te # from sbx new N8N_URL=silent-stormy-mint-5678.box.sandboxer.com # -5678.box.sandboxer.com sbx exec $id "docker volume create n8n_data" sbx exec $id --timeout 590 "docker pull docker.n8n.io/n8nio/n8n" sbx exec $id "docker run -d --name n8n --restart unless-stopped \ -p 5678:5678 \ -e GENERIC_TIMEZONE=UTC -e TZ=UTC \ -e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true \ -e N8N_HOST=$N8N_URL -e N8N_PROTOCOL=https \ -e N8N_WEBHOOK_URL=https://$N8N_URL/ \ -e N8N_PROXY_HOPS=1 \ -v n8n_data:/home/node/.n8n \ docker.n8n.io/n8nio/n8n" sbx exec $id "docker logs n8n 2>&1 | grep -A1 'Editor is now'" ``` ```text Editor is now accessible via: https://silent-stormy-mint-5678.box.sandboxer.com ``` Why each URL setting matters, per n8n's [webhook URL docs](https://docs.n8n.io/hosting/configuration/configuration-examples/webhook-url/): n8n builds webhook URLs from `N8N_PROTOCOL`, `N8N_HOST` and `N8N_PORT`. Behind a proxy that serves port 443, that gives the wrong address, so set the webhook URL yourself and `N8N_PROXY_HOPS=1`. Sandboxer's hosting proxy is that proxy. Use `N8N_WEBHOOK_URL`, not `WEBHOOK_URL`. We first started with `WEBHOOK_URL`, and n8n 2.45.4 logged: ```text There are deprecations related to your n8n setup. Please take the recommended actions to update your configuration: - WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for both test and production webhooks. ``` The log also said the Python task runner couldn't start because the image has no Python 3. That comes from n8n's image, not the sandbox; our test workflow has no Code node. The pull can exceed `sbx exec`'s default wait, so give it `--timeout` (up to 600 seconds) or `--detach` it. ## Host port 5678 and open the editor ```bash sbx host $id 5678 --title n8n ``` The URL it prints ends in `?_token=`. That token gates the whole port at the Sandboxer proxy: ```bash B=https://silent-stormy-mint-5678.box.sandboxer.com curl -s -o /dev/null -w "%{http_code}\n" $B/ # 401 curl -s -D - -o /dev/null -c jar.txt "$B/?_token=" | grep -iE "^HTTP|^location" # HTTP/2 302 # location: / curl -s -b jar.txt $B/ | grep -o "[^<]*" # n8n.io - Workflow Automation curl -s -b jar.txt $B/healthz # {"status":"ok"} ``` In a browser, open the full token link once. It sets a 30-day cookie and the editor loads. n8n has its own login on top. On a fresh volume, `/rest/settings` reported `"showSetupOnFirstLoad":true`: the first person to open the editor creates the owner account. Create it while the port is still private. Behind the token, n8n's own API answered `{"status":"error","message":"Unauthorized"}` until we signed in. ## Import a webhook workflow with the n8n CLI The workflow has two nodes: a Webhook trigger on `POST /webhook/hello`, and a Set node whose output becomes the response. ```json [ { "id": "helloWebhook01", "name": "Hello webhook", "active": false, "nodes": [ { "id": "9f1c2a3b-0000-4000-8000-000000000001", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 0], "webhookId": "9f1c2a3b-0000-4000-8000-0000000000aa", "parameters": { "httpMethod": "POST", "path": "hello", "responseMode": "lastNode", "options": {} } }, { "id": "9f1c2a3b-0000-4000-8000-000000000002", "name": "Reply", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [220, 0], "parameters": { "assignments": { "assignments": [ { "id": "a1", "name": "greeting", "type": "string", "value": "={{ 'hello ' + $json.body.name }}" }, { "id": "a2", "name": "receivedAt", "type": "string", "value": "={{ $now.toISO() }}" } ] }, "options": {} } } ], "connections": { "Webhook": { "main": [[{ "node": "Reply", "type": "main", "index": 0 }]] } }, "settings": {} } ] ``` Copy it in, import it, publish it and restart n8n. Per n8n's [CLI docs](https://docs.n8n.io/hosting/cli-commands/), `import:workflow` deactivates what it imports, `publish:workflow` replaces the deprecated `update:workflow --active=true` from n8n 2.0, and CLI changes take effect only after a restart. ```bash sbx scp hello-webhook.json $id:/home/user/hello-webhook.json sbx exec $id "docker cp hello-webhook.json n8n:/tmp/hello-webhook.json" sbx exec $id "docker exec n8n n8n import:workflow --input=/tmp/hello-webhook.json" sbx exec $id "docker exec n8n n8n publish:workflow --id=helloWebhook01" sbx exec $id "docker restart n8n" ``` ```text Importing 1 workflows... Successfully imported 1 workflow. Publishing workflow with ID: helloWebhook01 (current version) Note: Changes will not take effect if n8n is running. … Finished building workflow dependency index. Processed 0 draft workflows, 1 published workflows. ``` ## Call the webhook from outside On the private port, a caller has to carry the token. We tried three ways: ```bash curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Content-Type: application/json" \ -d '{"name":"Ada"}' $B/webhook/hello # 401 curl -s -X POST -H "Content-Type: application/json" \ -d '{"name":"Grace"}' "$B/webhook/hello?_token=" # {"greeting":"hello Grace","receivedAt":"2026-10-27T09:44:46.401Z"} (200) curl -s -b jar.txt -X POST -H "Content-Type: application/json" \ -d '{"name":"Ada"}' $B/webhook/hello # {"greeting":"hello Ada","receivedAt":"2026-10-27T09:44:39.365Z"} (200) ``` A `POST` with `?_token=` in the URL went straight through to n8n with a `200` and also set the cookie. (A `GET` with the token gets a `302` first, as above.) So a sender that lets you paste a full URL can call a private port. Then we made the port public: ```bash sbx host $id 5678 --public --title n8n curl -s -X POST -H "Content-Type: application/json" -d '{"name":"Linus"}' $B/webhook/hello # {"greeting":"hello Linus","receivedAt":"2026-10-27T09:45:09.256Z"} curl -s $B/rest/workflows # {"status":"error","message":"Unauthorized"} curl -s -X POST $B/webhook/nope # {"code":404,"message":"The requested webhook \"POST nope\" is not registered.", …} ``` With the port public, anyone can reach the editor's sign-in page and every webhook path. n8n's own login still guards the editor and API. ## Private or public: which to pick | | Private port (default) | Public port | | - | - | - | | Webhook URL you give the sender | `https://…-5678.box.sandboxer.com/webhook/hello?_token=` | `https://…-5678.box.sandboxer.com/webhook/hello` | | Request without the token | `401` from Sandboxer, n8n never sees it | Reaches n8n | | Who can reach the editor | Only people with the token, then n8n's login | Anyone, then n8n's login | | What the token gives away | The whole port, editor included: a sender that stores your webhook URL holds it | Nothing to give away | | Senders that reject query strings | Can't call it | Work | | Protect the webhook itself | The token already does | Use the Webhook node's Basic, Header or JWT auth ([n8n docs](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/)) | Keep the port private while you set up the owner account and build workflows. If you only test with your own scripts, stay private. Make it public when an outside service has to call in and can't carry the token, and turn on authentication in each Webhook node. Switching public to private later mints a new token: in our run the old link returned `401` and the new one `302`. Update the URL anywhere you pasted the old one. ## What survives stop and resume We noted n8n's container PID (2013) and ran: ```bash sbx stop $id --wait # 2.2 s sbx resume $id # 1.4 s sbx info $id # memoryRestored: true ``` While stopped, the webhook URL returned `404`. After resume, n8n answered 223 ms after `sbx resume` returned, with the same PID and start time: the process never restarted. Memory is restored only when the resume lands on the same CPU type. To see the other case, we stopped again and resumed with `--type default`, which always boots from disk: ```text memoryRestored: false, resumeReason: resized /proc/uptime: 19.66 n8n PID 615, started 2026-10-27T09:45:46Z {"greeting":"hello after cold boot","receivedAt":"2026-10-27T09:46:04.498Z"} ``` Docker is an enabled service, so it started at boot and `--restart unless-stopped` brought n8n back. The webhook answered 16.9 seconds after resume returned. The workflow was still published and the owner account still existed (`showSetupOnFirstLoad: false`), because both live in SQLite on the `n8n_data` volume. Without the restart policy, n8n stays down after a cold boot until you start it. Hosted routes come back either way. You can move to a bigger size on resume. Moving to a smaller size isn't supported yet (create a new sandbox and copy), so pick `small` or `default` up front. ## FAQ ### Which size does n8n need? `small` (2 vCPU, 4 GB, $0.018 per hour) ran this test with n8n using 731 MiB at idle. Use `default` for heavy workflows or many executions at once. ### Why does the editor show the wrong webhook URL? `N8N_WEBHOOK_URL` isn't set, or still points at `localhost`. Set it to `https://-5678.box.sandboxer.com/` and recreate the container. In n8n 2.35 and later, `WEBHOOK_URL` is deprecated. ### Does the n8n URL change after stop, resume or resize? No. The subdomain and hosted routes are kept. A fork gets a new subdomain and no hosted ports, so set the n8n URL variables again there. ### Do I need n8n's tunnel option? No. n8n's tunnel is for local development. The hosted URL already reaches the sandbox over HTTPS. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # Run Supabase Locally in a Cloud Sandbox Source: https://sandboxer.com/guides/run-supabase-locally-in-a-cloud-sandbox. Updated 2026-10-27. **Short answer:** Create a default Sandboxer sandbox, install the Supabase CLI from its .deb release, then run supabase init and supabase start. Docker is preinstalled. The first start took about 100 seconds. Host port 54323 (Studio) and 54321 (API) with sbx host for private HTTPS URLs. A memory-restoring stop and resume keeps the stack running. This is the Supabase CLI's **local** stack: Postgres, the API gateway, Auth, Storage, Realtime and Studio in Docker containers on one machine. It is not a Supabase cloud project. You don't need a Supabase account, and the keys it prints are shared local development defaults. ## At a glance | Step | What we saw (single runs, October 27, 2026, `default` size) | | - | - | | Install | Supabase CLI 2.122.0 from the `.deb` release; Docker 29.9.0 and Node 22 were already there | | `supabase start`, first run | About 100 seconds, most of it pulling images; 12 containers running | | `supabase stop` + `supabase start` | 11.7 s to stop, 25.8 s to start again with images cached; data kept | | Memory after start | About 2.1 GB used of 8 GB | | Studio on a hosted URL | `401` without the token, `302` then `200` with it | | REST call through the hosted URL | `200`, median 0.22 s over 20 calls from our test machine; 3.6 ms inside the sandbox | | `sbx stop --wait` + `sbx resume` | 2.2 s and 1.4 s; `memoryRestored: true`, same container PIDs, same rows | ## Which size We used `default` (4 vCPU, 8 GB RAM, 50 GB disk, $0.036/hour). Supabase's [local development guide](https://supabase.com/docs/guides/local-development/cli/getting-started) gives no minimum RAM, CPU or disk. The stack runs 12 containers and used about 2.1 GB of RAM once up, so `default` leaves headroom for your app and a test run next to it. We didn't try `small` (4 GB). Moving to a smaller size isn't supported yet, so pick the size you plan to stay on. ## Install the CLI and start the stack Supabase lists Homebrew, Linux packages and `npx supabase` as install methods. We used the Linux `.deb` from the [CLI releases](https://github.com/supabase/cli/releases): ```bash sbx new --name supabase-dev --type default id=sb_ydztcc1fj6 # the id that sbx new printed sbx exec $id 'curl -fsSLo /tmp/supabase.deb https://github.com/supabase/cli/releases/download/v2.122.0/supabase_2.122.0_linux_amd64.deb && sudo dpkg -i /tmp/supabase.deb && supabase --version' ``` ```text Unpacking supabase (2.122.0) ... Setting up supabase (2.122.0) ... 2.122.0 ``` The first `supabase start` pulls about a dozen images and runs longer than a foreground `sbx exec` waits (ours ended with exit `124`). Run it detached and poll a result file: ```bash sbx exec $id 'mkdir -p ~/myapp && cd ~/myapp && supabase init' sbx exec $id --detach 'cd ~/myapp && s=$(date +%s.%N); supabase start > start.log 2>&1; echo exit=$? > start.result; echo elapsed=$(python3 -c "print(round($(date +%s.%N)-$s,1))") >> start.result' sbx exec $id 'cat ~/myapp/start.result' ``` ```text exit=0 elapsed=101.0 ``` Part of the images had already been downloaded by our timed-out first attempt, so a completely fresh pull can take longer. The tail of `start.log`: ```text Started supabase local development setup. │ Studio │ http://127.0.0.1:54323 │ │ Mailpit │ http://127.0.0.1:54324 │ │ Project URL │ http://127.0.0.1:54321 │ │ REST │ http://127.0.0.1:54321/rest/v1 │ │ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ … Local dev security notice All services bind to 0.0.0.0 (network-accessible, not just localhost) API keys and JWT secrets are shared defaults. Do not use in production Studio, pgMeta (/pg/*), and analytics have no authentication ``` `supabase status -o env` prints the same values as shell variables, including `ANON_KEY`, `PUBLISHABLE_KEY` and `SERVICE_ROLE_KEY`. They are demo defaults, but don't paste the service role key into logs or chat anyway. ## Create a table and call it over REST The sandbox has no `psql`, so run it inside the database container. The container is named `supabase_db_`, and the project id defaults to the folder name. ```sql -- todos.sql create table public.todos ( id bigint generated always as identity primary key, task text not null, done boolean not null default false ); alter table public.todos enable row level security; create policy "anyone can read todos" on public.todos for select to anon using (true); insert into public.todos (task) values ('write the guide'), ('delete the sandbox'); ``` ```bash sbx scp todos.sql $id:/home/user/myapp/todos.sql sbx exec $id 'cd ~/myapp && docker exec -i supabase_db_myapp psql -U postgres -d postgres < todos.sql' sbx exec $id 'cd ~/myapp && eval $(supabase status -o env 2>/dev/null | grep ^ANON_KEY=); curl -s "http://127.0.0.1:54321/rest/v1/todos?select=id,task,done" -H "apikey: $ANON_KEY"' ``` ```text CREATE TABLE ALTER TABLE CREATE POLICY INSERT 0 2 [{"id":1,"task":"write the guide","done":false}, {"id":2,"task":"delete the sandbox","done":false}] ``` PostgREST serves the new table at once. An insert with the anon key was refused by the policy: `401` with `new row violates row-level security policy for table "todos"`. ## Host Studio and the API on private URLs The CLI binds every service to `0.0.0.0`, so `sbx host` reaches them without `--localhost`. Keep both private (the default): ```bash sbx host $id 54323 --title studio sbx host $id 54321 --title api ``` ```text {"ok":true,"type":"port.hosted","sandboxId":"sb_ydztcc1fj6","port":54323,"url":"https://vivid-able-fig-54323.box.sandboxer.com?_token=","access":"private",…} {"ok":true,"type":"port.hosted","sandboxId":"sb_ydztcc1fj6","port":54321,"url":"https://vivid-able-fig-54321.box.sandboxer.com?_token=","access":"private",…} ``` Open the Studio link in a browser. What `curl` returned: ```bash curl -s -o /dev/null -w '%{http_code}\n' https://vivid-able-fig-54323.box.sandboxer.com/ # 401 (no token) curl -sL -c jar -b jar -o /dev/null -w '%{http_code} %{url_effective}\n' "https://vivid-able-fig-54323.box.sandboxer.com?_token=" # 200 https://vivid-able-fig-54323.box.sandboxer.com/project/default ``` For the API, trade the token for the `__sbx_auth` cookie once, then call PostgREST with the anon key as usual: ```bash API=https://vivid-able-fig-54321.box.sandboxer.com curl -s -o /dev/null -c api.jar "$API/?_token=" # 302, sets the cookie curl -s -b api.jar "$API/rest/v1/todos?select=id,task,done" -H "apikey: $ANON_KEY" -w '\nHTTP %{http_code} in %{time_total}s\n' ``` ```text [{"id":1,"task":"write the guide","done":false}, {"id":2,"task":"delete the sandbox","done":false}] HTTP 200 in 0.240160s ``` Over 20 calls from our test machine to the sandbox (region `eu-central`), the median was 0.22 s. Inside the sandbox the same call took 3.6 ms, so nearly all of that is the network path. **Don't make these ports public.** Studio has no login, as the CLI's notice says. In this CLI version the local gateway also answered anon reads with no `apikey` header and with `apikey: nope` (both `200`). On a private URL the `_token` is the only thing keeping strangers out. Treat the full URL and the cookie like a password. ## Stop and resume: the stack keeps running Before stopping, the sandbox had been up 325 seconds and `supabase_db_myapp` had PID 13272. ```bash sbx stop $id --wait # 2.2 s sbx resume $id # 1.4 s sbx info $id # "memoryRestored": true sbx exec $id 'cat /proc/uptime; docker inspect -f "{{.Name}} {{.State.Pid}}" $(docker ps -q) | sort | head -3; docker ps -q | wc -l' ``` ```text 342.25 1001.76 /supabase_analytics_myapp 13537 /supabase_auth_myapp 14153 /supabase_db_myapp 13272 12 ``` Uptime kept counting and the PIDs are unchanged: memory was restored, so nothing restarted. The REST call through the hosted URL returned the same two rows, and Studio answered `200`, with no new `sbx host` call. Routes and tokens are kept across stop and resume. ## If the sandbox boots from disk instead Resume boots from disk, keeping files but not processes, when you resize on resume or no machine with the same CPU type is free. Docker is an enabled systemd service and the database lives in Docker volumes on disk, so what comes back depends on each container's restart policy. The CLI sets them for you: ```bash sbx exec $id 'docker inspect -f "{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}" $(docker ps -q) | sort' ``` ```text /supabase_analytics_myapp restart=unless-stopped /supabase_auth_myapp restart=unless-stopped /supabase_db_myapp restart=unless-stopped /supabase_edge_runtime_myapp restart=no … (the other 8 containers: unless-stopped) ``` We didn't resize, so we tested the same mechanism by restarting the Docker daemon (`sudo systemctl restart docker`). Eleven containers came back healthy and the rows were still there. `supabase_edge_runtime_myapp` stayed `Exited (15)`, and `supabase start` replied `supabase start is already running.` without starting it. `docker start supabase_edge_runtime_myapp` brought it back. Do the same after a cold boot if you use Edge Functions. ## Clean up `supabase stop` keeps your data in Docker volumes (`supabase stop --no-backup` deletes it). Deleting the sandbox removes everything: ```bash sbx delete $id --yes ``` After the delete, the hosted API URL returned `404`. ## FAQ ### Is this the same as a Supabase cloud project? No. `supabase start` runs the open-source services in Docker inside your sandbox, with no Supabase account and shared demo keys. Use it for development and tests; it doesn't replace a hosted project. ### Can my app outside the sandbox use the hosted API URL? Yes, if it can send the `__sbx_auth` cookie: open the `?_token=` link once, keep the cookie, and pass the anon key in the `apikey` header. We tested this with `curl`. Code running inside the sandbox can call `http://127.0.0.1:54321` directly. ### Why did the first start time out? It pulls about a dozen images, which ran past the time a foreground `sbx exec` waits, and the command ended with exit `124`. Start it with `sbx exec $id --detach` and poll a result file, as shown above. With images cached, a restart took 25.8 seconds. ### Do I lose my tables when the sandbox stops? No. A memory-restoring resume left all 12 containers running with the same PIDs. A boot from disk keeps the Docker volumes, and 11 of the 12 containers restart on their own. ### Can I connect to Postgres directly? Port 54322 is the database. From your laptop, forward it over SSH with `sbx forward $id --remote 54322` rather than a hosted URL, which serves HTTP. We didn't run this step; see [SSH](/docs/ssh#forward-ports). ## Related - [Hosting](/docs/hosting) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) --- # How to Run Postgres with pgvector in a Cloud Sandbox Source: https://sandboxer.com/guides/run-postgres-with-pgvector-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official pgvector/pgvector:pg17 Docker image in a Sandboxer microVM with --shm-size=1g and --restart unless-stopped, then create the vector extension and an HNSW index. On 10,000 random 384-dimension vectors, a top-10 query took 6 to 7 ms without the index and 2.6 to 5.9 ms with it. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB): PostgreSQL 17.11, pgvector 0.8.7, Docker image `pgvector/pgvector:pg17`. The vectors are random numbers made by Postgres itself. There is no embedding model in this guide, so the timings measure the database, not a model. All timings are single runs or three back-to-back runs; your numbers will differ. | Step | Result | | - | - | | Pull the image and start Postgres | 8.6 s | | Insert 10,000 vectors of 384 dimensions | 1.4 s | | Build the HNSW index | 1.3 s (20 MB index, 16 MB table) | | Top-10 cosine query, no index | 6.0 to 6.8 ms (warm), sequential scan | | Same query, HNSW index, default `ef_search` (40) | 2.6 to 5.9 ms, 7 of the 10 exact neighbors found | | Same query, HNSW index, `ef_search = 200` | 6.6 to 7.4 ms, 10 of 10 found | | Same query from a laptop through an SSH tunnel | 155 ms median round trip | | `sbx stop --wait` then `sbx resume` | Memory restored: Postgres kept running | | Resume on a bigger size (boots from disk) | Docker restarted Postgres; rows and index intact | ## 1. Start pgvector in a sandbox pgvector publishes a Docker image with the extension already built ([pgvector install docs](https://github.com/pgvector/pgvector#docker)). Docker is preinstalled in every sandbox, so this is one command: ```bash sbx new --name pgvector --type default --no-env id=sb_yra5cldf6n # the id that sbx new printed sbx exec $id --timeout 300 "docker run -d --name pg --restart unless-stopped --shm-size=1g \ -e POSTGRES_PASSWORD=pw -p 127.0.0.1:5432:5432 -v pgdata:/var/lib/postgresql/data \ pgvector/pgvector:pg17" sbx exec $id "until docker exec pg pg_isready -q; do sleep 0.5; done; docker exec pg pg_isready" ``` ```text Status: Downloaded newer image for pgvector/pgvector:pg17 /var/run/postgresql:5432 - accepting connections ``` Three flags matter: - `--shm-size=1g`. Docker gives a container 64 MB of `/dev/shm` by default. Our first run left it out, and the parallel HNSW build failed (output below). With 1 GB, the same build worked. - `--restart unless-stopped`. When the sandbox boots from disk instead of restoring memory, Docker starts Postgres again. - `-p 127.0.0.1:5432:5432`. Postgres listens inside the VM only. Nothing outside can reach it. You connect through SSH (step 4). This is the error without `--shm-size`: ```text ERROR: could not resize shared memory segment "/PostgreSQL.1852724404" to 265326016 bytes: No space left on device ``` ## 2. Load 10,000 vectors Save this as `load.sql`. It makes 10,000 rows of 384 random `real` values each. 384 is the size of many small text-embedding models; use your model's size if you have one. ```sql CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE items (id bigint PRIMARY KEY, embedding vector(384) NOT NULL); \timing on INSERT INTO items SELECT i, array_agg(random()::real ORDER BY d)::vector(384) FROM generate_series(1, 10000) AS i, generate_series(1, 384) AS d GROUP BY i; \timing off SELECT count(*), vector_dims(embedding) AS dims FROM items GROUP BY 2; SELECT extversion FROM pg_extension WHERE extname = 'vector'; ``` ```bash sbx scp ./load.sql $id:/home/user/ sbx exec $id "docker exec -i pg psql -U postgres -v ON_ERROR_STOP=1 < load.sql" ``` ```text CREATE EXTENSION CREATE TABLE INSERT 0 10000 Time: 1373.018 ms (00:01.373) count | dims -------+------ 10000 | 384 extversion ------------ 0.8.7 ``` ## 3. Measure the query with and without an index Save this as `knn.sql`. It takes row 42's vector as the query and asks for the 10 nearest rows by cosine distance (`<=>`): ```sql SELECT embedding AS q FROM items WHERE id = 42 \gset EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10; ``` Without an index, Postgres reads every row and sorts: ```bash sbx scp ./knn.sql $id:/home/user/ sbx exec $id "docker exec -i pg psql -U postgres < knn.sql" ``` ```text Limit (actual time=4.618..5.980 rows=10 loops=1) -> Gather Merge (actual time=4.618..5.979 rows=10 loops=1) Workers Planned: 1 Workers Launched: 1 -> Sort (actual time=3.261..3.262 rows=8 loops=2) Sort Key: ((embedding <=> '[…] Sort Method: top-N heapsort Memory: 25kB Worker 0: Sort Method: top-N heapsort Memory: 25kB -> Parallel Seq Scan on items (actual time=0.005..2.965 rows=5000 loops=2) Planning Time: 0.038 ms Execution Time: 5.999 ms ``` Now build an HNSW index with the operator class that matches the query's distance (`vector_cosine_ops` for `<=>`). Save as `index.sql`: ```sql SET maintenance_work_mem = '256MB'; \timing on CREATE INDEX items_embedding_hnsw ON items USING hnsw (embedding vector_cosine_ops); \timing off SELECT pg_size_pretty(pg_relation_size('items_embedding_hnsw')) AS index_size, pg_size_pretty(pg_table_size('items')) AS table_size; ``` ```text CREATE INDEX Time: 1293.373 ms (00:01.293) index_size | table_size ------------+------------ 20 MB | 16 MB ``` Run `knn.sql` again. The plan is now an index scan: ```text Limit (actual time=2.486..2.546 rows=10 loops=1) -> Index Scan using items_embedding_hnsw on items (actual time=2.484..2.544 rows=10 loops=1) Order By: (embedding <=> '[…] Planning Time: 0.083 ms Execution Time: 2.572 ms ``` Three runs took 2.6, 3.0 and 5.9 ms, against 6.0 to 6.8 ms for the warm sequential scan. ### Check what the index gives up HNSW is approximate. To see how many true neighbors it returns, compare it with an exact scan. Save as `recall.sql`: ```sql SELECT embedding AS q FROM items WHERE id = 7 \gset CREATE TEMP TABLE approx AS SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10; SET enable_indexscan = off; CREATE TEMP TABLE exact AS SELECT id FROM items ORDER BY embedding <=> :'q' LIMIT 10; SELECT count(*) AS overlap_of_10 FROM approx JOIN exact USING (id); ``` ```text overlap_of_10 --------------- 7 ``` With the default search list (`hnsw.ef_search = 40`, see the [pgvector query options](https://github.com/pgvector/pgvector#query-options)), the index found 7 of the 10 exact neighbors. With `SET hnsw.ef_search = 200;` at the top of both files, it found all 10, and the query took 6.6 to 7.4 ms, the same as no index. Two things explain this. Uniform random vectors are a hard case for approximate search, because every point is about the same distance from every other point. Real embeddings cluster, so recall at the default setting is usually better; measure it on your own data with the file above. And 10,000 rows is small: a sequential scan of 16 MB is already fast. Measure at your real row count before you decide the index is worth its memory and build time. ## 4. Connect from your laptop through an SSH tunnel Postgres listens on `127.0.0.1` inside the VM, so forward a local port to it over SSH. Add the CLI's SSH stanza once, connect once so the CLI authorizes its key, then open the tunnel: ```bash sbx ssh-config --install # adds a Host *.sbx block (ProxyCommand sbx proxy %h) sbx ssh $id true # authorizes the CLI's key on this sandbox ssh -f -N -o ExitOnForwardFailure=yes -L 15432:127.0.0.1:5432 $id.sbx ``` Any Postgres client on your laptop can now use `127.0.0.1:15432`. We used Python with `psycopg`: ```python import statistics, time import psycopg with psycopg.connect("host=127.0.0.1 port=15432 user=postgres password=pw dbname=postgres") as conn: print(conn.execute("SELECT extversion FROM pg_extension WHERE extname = 'vector'").fetchone()[0]) q = conn.execute("SELECT embedding::text FROM items WHERE id = 42").fetchone()[0] times = [] for _ in range(20): t0 = time.perf_counter() rows = conn.execute( "SELECT id, round((embedding <=> %s::vector)::numeric, 4) AS distance " "FROM items ORDER BY embedding <=> %s::vector LIMIT 5", (q, q), ).fetchall() times.append((time.perf_counter() - t0) * 1000) print(rows) print(f"median {statistics.median(times):.1f} ms, min {min(times):.1f} ms over {len(times)} queries") ``` ```text 0.8.7 [(9123, Decimal('0.2115')), (6790, Decimal('0.2185')), (8243, Decimal('0.2205')), (900, Decimal('0.2211')), (4904, Decimal('0.2216'))] median 155.1 ms, min 154.8 ms over 20 queries ``` The query itself takes about 3 ms inside the VM. The rest of the 155 ms is the network path from our client to the sandbox and back, which depends on where you are. For a quick check this is fine. For load tests or an app that queries in a loop, run the client in the sandbox too. Close the tunnel when you are done by killing that `ssh` process. [`sbx forward`](/docs/ssh#forward-ports) is the CLI's built-in alternative to `ssh -L`; we tested the `ssh -L` route only. The query vector's own row (42) is not in the results above. With the HNSW index and the default `ef_search`, an approximate search can miss even an exact match. That is the same recall trade-off as in step 3. ## 5. What survives a stop and resume We ran `CHECKPOINT`, then stopped and resumed the sandbox: ```bash sbx exec $id "docker exec pg psql -U postgres -c CHECKPOINT" sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'; docker exec pg psql -U postgres -tAc 'select count(*) from items'" ``` ```text pg Up 2 minutes 10000 ``` The stop took 2.2 seconds and the resume 1.4 seconds. The resume reported `memoryRestored: true`: the container was never restarted (`Up 2 minutes`), and the tunnel script worked again with the same results. A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced that case with `sbx resume $id --type large` (`memoryRestored: false`, `resumeReason: "resized"`). Docker's restart policy brought Postgres back, and the data survived: ```text pg Up 2 seconds 10000 items_pkey items_embedding_hnsw ``` So the table, the data and the HNSW index live on the sandbox disk and survive both paths. Only a process you started by hand without a restart policy is lost on a boot from disk. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy the data. See [snapshots](/docs/snapshots#resume) for the rules. ## 6. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Why does CREATE INDEX fail with "No space left on device"? Docker's default `/dev/shm` is 64 MB, and a parallel index build in Postgres uses shared memory. Start the container with `--shm-size=1g`. With a 64 MB `/dev/shm`, our build failed at once; with 1 GB, it finished in 1.3 seconds. ### Do I need the HNSW index for 10,000 rows? Probably not. In our run, the sequential scan took 6 to 7 ms and always returned the exact top 10. The index was up to twice as fast at default settings but missed 3 of the 10 neighbors on random data. Measure at your real row count and on your real embeddings. ### Which operator class do I use? Match it to the distance in your `ORDER BY`: `vector_cosine_ops` for `<=>` (cosine), `vector_l2_ops` for `<->` (Euclidean), `vector_ip_ops` for `<#>` (inner product). If they don't match, Postgres can't use the index and falls back to a sequential scan. The full list is in the [pgvector README](https://github.com/pgvector/pgvector#hnsw). ### Can I expose Postgres on a public URL instead of a tunnel? A [hosted URL](/docs/hosting) is an HTTPS address, built for web apps and APIs. A Postgres client expects a plain Postgres connection on a TCP port, so we used SSH and didn't test a hosted URL for this. The tunnel also keeps the database private: Postgres listens only on `127.0.0.1` in the VM, and opening the tunnel takes your API key. ### Will my data survive if the sandbox auto-stops? Yes. Auto-stop is a normal stop: the disk and memory are saved, and `sbx resume` brings it back. Run `CHECKPOINT` before a planned stop so Postgres has flushed its data files, and keep `--restart unless-stopped` for the times a resume boots from disk. ## Related - [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres) - [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [SSH and file transfer](/docs/ssh) --- # How Do I Run Redis Stack and Redis Insight in a Cloud Sandbox? Source: https://sandboxer.com/guides/run-redis-stack-in-a-sandbox. Updated 2026-10-27. **Short answer:** Redis 8 replaces Redis Stack, so run the official redis:8 image in a Sandboxer microVM with AOF on and --restart unless-stopped. JSON, search and vector sets are built in. Put Redis Insight on a private sbx host URL with --localhost, and reach Redis from your laptop with redis-cli over ssh -L. Data survived stop and resume. ## What we ran Everything below ran on a `default` sandbox (4 vCPU, 8 GB) on 2026-10-27. Sandbox ids and tokens are replaced with `$id` and ``. | Check | Result | | - | - | | Image | `redis:8` (Redis 8.10.3), modules `search`, `ReJSON`, `vectorset`, `timeseries`, `bf` loaded | | Redis Insight | `redis/redisinsight:latest` (3.8.0), healthy 5 s after `docker run` | | `redis-benchmark -q`, inside the VM | SET 75,245 req/s, GET 86,059 req/s; with `-P 16`, GET 990,099 req/s | | `redis-cli --latency`, inside the VM | about 0.14 ms average | | `redis-cli --latency` from our machine over `ssh -L` | 127 ms average, 124 ms minimum | | Private Insight URL | `401` without the token, `302` with it, then `200` with the cookie | | `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, every key, index and the Insight URL still there | | `systemctl restart docker` (what a boot from disk looks like) | Both containers came back on their own, data reloaded from the AOF | ## Redis Stack or redis:8? The Redis docs now say that [Redis 8 in Redis Open Source replaces Redis Stack](https://redis.io/docs/latest/operate/oss_and_stack/), and the [Docker install page](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/docker/) uses the `redis:` image. The JSON, search and vector features that used to need Redis Stack ship inside `redis:8`. Redis Insight, which the old Stack image bundled, is now its own image, [`redis/redisinsight`](https://redis.io/docs/latest/operate/redisinsight/install/install-on-docker/). So this guide runs two containers. ## 1. Start Redis with AOF and a restart policy Docker is preinstalled in every sandbox (see [Docker and nested KVM inside a sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox)). ```bash sbx new --type default --name redis-stack-1 id=sb_xxxxxxxxxx # the id it printed sbx exec $id "docker network create redis-net && docker volume create redis-data && \ docker run -d --name redis --network redis-net --restart unless-stopped \ -p 127.0.0.1:6379:6379 -v redis-data:/data \ redis:8 redis-server --appendonly yes --appendfsync everysec" ``` Two choices matter here: - **`127.0.0.1:6379`, not `6379`.** Redis has no password by default. Every sandbox has a public IPv6 address. Its firewall (`ufw`) is closed except SSH ([Security](/docs/security)), but Docker can [bypass ufw rules](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw) on some setups. Binding to loopback keeps Redis off the network whatever the firewall does. You reach it over SSH instead. - **`--appendonly yes`.** Redis writes every change to an append-only file in the `redis-data` volume and fsyncs once a second, so a restart loses at most about a second of writes. Check it: ```bash sbx exec $id "docker exec redis redis-cli INFO server | grep redis_version; \ docker exec redis redis-cli MODULE LIST; docker exec redis redis-cli CONFIG GET appendonly" ``` ```text redis_version:8.10.3 name search ver 81001 path /usr/local/lib/redis/modules//redisearch.so … name vectorset … name ReJSON … appendonly yes ``` The module list also shows `bf` (Bloom and other probabilistic types) and `timeseries`. ## 2. Strings, a JSON document, search and a vector query Save this as `demo.sh`, copy it in with `sbx scp demo.sh $id:/home/user/` and run `sbx exec $id "bash demo.sh"`. It calls `redis-cli` inside the container, one command per line. ```bash r() { docker exec redis redis-cli "$@"; } r SET greeting "hello from the sandbox" r GET greeting r JSON.SET doc:1 '$' '{"title":"Pause a sandbox","tags":["stop","resume"],"views":12}' r JSON.NUMINCRBY doc:1 '$.views' 1 r JSON.GET doc:1 '$.title' '$.views' r JSON.SET doc:2 '$' '{"title":"Fork a sandbox","tags":["fork"],"views":40}' # Index the JSON documents, then query them r FT.CREATE idx:docs ON JSON PREFIX 1 doc: SCHEMA \ '$.title' AS title TEXT '$.tags[*]' AS tags TAG '$.views' AS views NUMERIC SORTABLE r FT.SEARCH idx:docs 'sandbox @views:[10 50]' SORTBY views DESC RETURN 2 title views r FT.SEARCH idx:docs '@tags:{resume}' RETURN 1 title # A vector set: add three 3-dimensional vectors, find the two closest to a query r VADD emb VALUES 3 0.9 0.1 0.0 cat r VADD emb VALUES 3 0.8 0.2 0.1 dog r VADD emb VALUES 3 0.0 0.1 0.9 car r VSIM emb VALUES 3 0.85 0.15 0.05 WITHSCORES COUNT 2 ``` ```text OK hello from the sandbox OK [13] {"$.views":[13],"$.title":["Pause a sandbox"]} OK OK 2 doc:2 views 40 title Fork a sandbox doc:1 views 13 title Pause a sandbox 1 doc:1 title Pause a sandbox 1 1 1 cat 0.9978907108306885 dog 0.9973737895488739 ``` The first search finds both documents, sorted by `views`. The tag search finds only `doc:1`. [Vector sets](https://redis.io/docs/latest/develop/data-types/vector-sets/) are new in Redis 8 and take plain numbers, so you can try similarity search from the shell without encoding binary vectors. `FT.SEARCH` with a `VECTOR` field and a `KNN` query also works, but needs the query vector as a binary blob, which is easier from a client library. ## 3. Redis Insight on a private URL Run Insight on the same Docker network, so it reaches Redis by the name `redis`. Bind it to loopback too, and host it with `--localhost`, which makes the sandbox relay the hosted URL to `127.0.0.1` (see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)). ```bash sbx exec $id "docker run -d --name redisinsight --network redis-net --restart unless-stopped \ -p 127.0.0.1:5540:5540 -v redisinsight:/data redis/redisinsight:latest" sbx exec $id "curl -s 127.0.0.1:5540/api/health/" # {"status":"up"} sbx host $id 5540 --localhost --title "Redis Insight" ``` ```text https://lunar-happy-coral-5540.box.sandboxer.com?_token= ``` The URL is private by default: ```bash curl -s -o /dev/null -w "%{http_code}\n" https://lunar-happy-coral-5540.box.sandboxer.com/ # 401 curl -s -c jar -o /dev/null -w "%{http_code}\n" "https://lunar-happy-coral-5540.box.sandboxer.com/?_token=" # 302 curl -s -b jar https://lunar-happy-coral-5540.box.sandboxer.com/ | grep -o ".*" # Redis Insight ``` In the browser, open the token link, choose **Add Redis database**, and enter host `redis`, port `6379`. We did the same through Insight's own API, and it detected the modules: ```bash sbx exec $id "curl -s -X POST 127.0.0.1:5540/api/databases -H content-type:application/json \ -d '{\"name\":\"sandbox-redis\",\"host\":\"redis\",\"port\":6379}'" ``` ```text {… "host":"redis","port":6379,"name":"sandbox-redis", … "provider":"REDIS_COMMUNITY_EDITION" …} ``` The Redis Insight docs list `RI_REDIS_HOST`, `RI_REDIS_PORT` and `RI_REDIS_ALIAS` for [preconfiguring a connection](https://redis.io/docs/latest/operate/redisinsight/configuration/). With Insight 3.8.0 in our run, setting them didn't add a database (`/api/databases` returned `[]`), so we added it by hand. ## 4. redis-cli from your laptop over ssh -L Redis listens on `127.0.0.1` inside the VM, so forward a local port to it over SSH. Save the stanza that `sbx ssh-config` prints and pass it with `-F`, as in the [MySQL guide](/guides/run-mysql-or-mariadb-in-a-sandbox): ```bash sbx ssh-config > sbx_ssh_config ssh -F ./sbx_ssh_config -f -N -o ExitOnForwardFailure=yes -L 6380:127.0.0.1:6379 $id.sbx redis-cli -p 6380 PING redis-cli -p 6380 GET greeting redis-cli -p 6380 JSON.GET doc:1 '$.views' redis-cli -p 6380 VSIM emb ELE car COUNT 1 redis-cli -p 6380 --latency ``` ```text PONG hello from the sandbox [13] car min: 124.382, max: 249.666, avg: 127.468 (58 samples) ``` The tunnel goes through the Sandboxer API over HTTPS, so each round trip from our test machine cost about 127 ms, against 0.14 ms inside the VM. Use the tunnel for inspecting and debugging. Run anything chatty (a worker, a test suite, a benchmark) inside the sandbox. In our run the same tunnel kept working across the stop and resume below. ## 5. A measured number: redis-benchmark ```bash sbx exec $id "docker exec redis redis-benchmark -q -n 100000 -t set,get,incr,lpush,lpop" sbx exec $id "docker exec redis redis-benchmark -q -n 100000 -P 16 -t set,get" ``` ```text SET: 75244.55 requests per second, p50=0.319 msec GET: 86058.52 requests per second, p50=0.239 msec INCR: 81300.81 requests per second, p50=0.287 msec LPUSH: 77459.34 requests per second, p50=0.287 msec LPOP: 99403.58 requests per second, p50=0.223 msec SET: 591716.00 requests per second, p50=1.007 msec GET: 990099.00 requests per second, p50=0.415 msec ``` That is with AOF on (`everysec`), 50 clients and the benchmark running on the same 4 vCPUs as Redis. The benchmark writes keys such as `key:__rand_int__`, so run it on a scratch database or `FLUSHALL` afterwards, and run `BGREWRITEAOF` to shrink the append-only file (ours grew to 19 MB and went back to 616 bytes). ## 6. What survives a stop and resume ```bash sbx exec $id "docker exec redis redis-cli SET before_stop \"\$(date -u +%T)\"" # OK sbx stop $id --wait sbx resume $id sbx info $id --json | grep -o '"memoryRestored":[a-z]*' # "memoryRestored":true sbx exec $id "docker exec redis redis-cli GET before_stop" # "10:29:33" ``` While the sandbox was stopped, the Insight URL returned `404`. After `sbx resume` it returned `200` with the old cookie, `DBSIZE` was unchanged, and the containers had kept running. Resume restores memory in the normal case. When it boots from disk instead (for example after a resume onto a different CPU type), files are kept and processes are not. That is where the restart policy and AOF earn their place. To check that path without waiting for it, we restarted the Docker daemon: ```bash sbx exec $id "systemctl is-enabled docker; sudo systemctl restart docker; sleep 5; \ docker ps --format '{{.Names}} {{.Status}}'; docker exec redis redis-cli GET before_stop" ``` ```text enabled redisinsight Up 5 seconds redis Up 5 seconds 10:29:33 ``` Docker starts at boot, `unless-stopped` brings both containers back, and Redis reloads its data, search index and vector set from the AOF. ## Size and cost Idle, Redis used 6.5 MiB and Redis Insight 163 MiB, and the two images are 149 MB and 370 MB. A `small` sandbox is enough to try it. We used `default` ($0.036/hour while running) for the benchmark. A stopped sandbox isn't billed. ## Clean up ```bash sbx delete $id --yes ``` ## FAQ ### Is redis/redis-stack still the image to use? Redis's docs say Redis 8 replaces Redis Stack, and their Docker page uses `redis:`. JSON, search, time series, probabilistic types and vector sets come built in, and Redis Insight is a separate `redis/redisinsight` image. ### Should I set a Redis password? Set one with `--requirepass` if anything other than you can reach the port. In this setup Redis is bound to `127.0.0.1` inside the VM, so only processes in the sandbox and your SSH tunnel can reach it. Don't publish 6379 on `0.0.0.0` without a password. ### Can I host Redis itself on an HTTPS URL? No. `sbx host` serves HTTP and WebSockets through an HTTPS proxy, and Redis speaks its own TCP protocol. Reach Redis over an SSH tunnel from your laptop, as in step 4, and host only Redis Insight. ### Will my data survive an auto-stop? Yes. An auto-stop is the same memory-preserving stop as `sbx stop`. If a resume boots from disk, Docker restarts the containers and Redis reloads from the AOF in the `redis-data` volume. ### Why is redis-cli slow from my laptop? The SSH tunnel crosses the Sandboxer API, so each command is a full round trip over the internet (about 127 ms in our test). Pipelines and `--pipe` help. For real load, run the client in the sandbox. ## Related - [Run MySQL or MariaDB in a sandbox](/guides/run-mysql-or-mariadb-in-a-sandbox) - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [SSH and file transfer](/docs/ssh) --- # How to Run MySQL or MariaDB in a Cloud Sandbox Source: https://sandboxer.com/guides/run-mysql-or-mariadb-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official mysql:8.4 or mariadb:11.8 Docker image in a Sandboxer microVM, bound to 127.0.0.1 with --restart unless-stopped. Load the Sakila sample database, then reach it from your laptop with ssh -L through the stanza sbx ssh-config prints. Data survived a stop and resume. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB, Ubuntu 24.04): MySQL Community Server 8.4.13 from the `mysql:8.4` image, then MariaDB 11.8.9 from the `mariadb:11.8` image, both with MySQL's public [Sakila sample database](https://dev.mysql.com/doc/sakila/en/). Timings are single runs or a few back-to-back runs; yours will differ. | Step | Result | | - | - | | `sbx new --type default` | 3.2 s to ready | | Pull `mysql:8.4` and start it | 20 s (most of it the image pull) | | Load Sakila (schema + data) | 1.4 s, 16,044 rentals | | Start MariaDB 11.8 (image already pulled) | 4.9 s to accepting TCP | | `mysqlslap`, Sakila join, 160 queries, 1 client | 5.1 to 5.6 s (31 to 35 ms a query) | | Same, 16 clients | 1.6 to 2.9 s | | Query from a laptop through `ssh -L` | 155 ms median round trip | | `sbx stop --wait` then `sbx resume` | 2.2 s stop, 1.4 s resume, `memoryRestored: true`, row intact | | Docker daemon restart | `--restart unless-stopped` brought MySQL back, row intact | ## 1. Start MySQL in a sandbox Docker is preinstalled in every sandbox. Use the official image as its [Docker Hub page](https://hub.docker.com/_/mysql) documents: ```bash sbx new --name mysql --type default --no-env id=sb_7hicnttcab # the id that sbx new printed sbx exec $id --timeout 300 "docker run -d --name mysql --restart unless-stopped \ -e MYSQL_ROOT_PASSWORD=pw -p 127.0.0.1:3306:3306 -v mysqldata:/var/lib/mysql mysql:8.4" sbx exec $id "until docker exec mysql mysql -uroot -ppw -h127.0.0.1 -e 'select 1' >/dev/null 2>&1; do sleep 0.5; done docker exec mysql mysql -uroot -ppw -e 'select version()' 2>/dev/null" ``` ```text version() 8.4.13 ``` Three choices matter: - `-p 127.0.0.1:3306:3306`. MySQL listens inside the VM only. Nothing outside can reach it. You connect through SSH (step 4). - `-v mysqldata:/var/lib/mysql`. The data lives in a named volume on the sandbox disk. - `--restart unless-stopped`. If the sandbox boots from disk instead of restoring memory, Docker starts MySQL again (step 5). Wait on a TCP query (`-h127.0.0.1`), not `mysqladmin ping`. On first start, the image runs a temporary server on the socket only, creates the root user, then restarts. Our first script waited on `mysqladmin ping`, which answered from the temporary server, and the next query ran before the real server was up. The logs show the two phases: ```text [Note] [Entrypoint]: Temporary server started. [Note] [Entrypoint]: Temporary server stopped [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.13' socket: '/var/run/mysqld/mysqld.sock' port: 3306 ``` ## 2. Load the Sakila sample database Sakila is MySQL's sample DVD-rental schema: films, actors, stores, rentals and payments. Download it from MySQL inside the sandbox and pipe both files into the server: ```bash sbx exec $id --timeout 300 "curl -fsSLO https://downloads.mysql.com/docs/sakila-db.tar.gz && tar xzf sakila-db.tar.gz docker exec -i mysql mysql -uroot -ppw < sakila-db/sakila-schema.sql docker exec -i mysql mysql -uroot -ppw < sakila-db/sakila-data.sql" ``` The load took 1.4 seconds. Check the counts with `COUNT(*)`, not `information_schema.tables.table_rows`: right after the load, `table_rows` is an estimate and left `rental` and `payment` out of our top five. ```text +---------+----------+-------+ | rentals | payments | films | +---------+----------+-------+ | 16044 | 16044 | 1000 | +---------+----------+-------+ ``` Now a real query. Save this as `top.sql`. It joins five tables to rank film categories by revenue: ```sql SELECT c.name AS category, COUNT(*) AS rentals, SUM(p.amount) AS revenue FROM sakila.payment p JOIN sakila.rental r ON r.rental_id = p.rental_id JOIN sakila.inventory i ON i.inventory_id = r.inventory_id JOIN sakila.film_category fc ON fc.film_id = i.film_id JOIN sakila.category c ON c.category_id = fc.category_id GROUP BY c.name ORDER BY revenue DESC LIMIT 5; ``` ```bash sbx scp ./top.sql $id:/home/user/ sbx exec $id "docker exec -i mysql mysql -uroot -ppw -t < top.sql" ``` ```text +-----------+---------+---------+ | category | rentals | revenue | +-----------+---------+---------+ | Sports | 1179 | 5314.21 | | Sci-Fi | 1101 | 4756.98 | | Animation | 1166 | 4656.30 | | Drama | 1060 | 4587.39 | | Comedy | 941 | 4383.58 | +-----------+---------+---------+ ``` ## 3. Measure it with mysqlslap The `mysql:8.4` image doesn't include `mysqlslap`. Its `/usr/bin` has `mysql`, `mysqladmin`, `mysqldump` and `mysqlsh`, but no `mysqlslap`. Install Ubuntu's client in the sandbox (4.6 s) and point it at the container over TCP: ```bash sbx exec $id --timeout 300 "sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq mysql-client && mysqlslap --version" ``` ```text mysqlslap Ver 8.0.46-0ubuntu0.24.04.4 for Linux on x86_64 ((Ubuntu)) ``` Save as `slap.sh`. The first run repeats the revenue join, the second uses `mysqlslap`'s own generated mixed read/write load ([mysqlslap options](https://dev.mysql.com/doc/refman/8.4/en/mysqlslap.html)): ```bash export MYSQL_PWD=pw mysqlslap -h 127.0.0.1 -uroot --create-schema=sakila \ --query="SELECT c.name, SUM(p.amount) FROM payment p JOIN rental r USING (rental_id) JOIN inventory i USING (inventory_id) JOIN film_category fc USING (film_id) JOIN category c USING (category_id) GROUP BY c.name" \ --concurrency=1,4,16 --iterations=3 --number-of-queries=160 mysqlslap -h 127.0.0.1 -uroot --auto-generate-sql --auto-generate-sql-load-type=mixed \ --concurrency=1,4,16 --iterations=3 --number-of-queries=2000 ``` ```bash sbx scp ./slap.sh $id:/home/user/ sbx exec $id --timeout 300 "bash slap.sh" ``` ```text Benchmark Average number of seconds to run all queries: 5.057 seconds Minimum number of seconds to run all queries: 4.191 seconds Maximum number of seconds to run all queries: 5.496 seconds Number of clients running queries: 1 Average number of queries per client: 160 … Benchmark Average number of seconds to run all queries: 1.587 seconds … Number of clients running queries: 16 Average number of queries per client: 10 … Benchmark Average number of seconds to run all queries: 0.633 seconds … Number of clients running queries: 16 Average number of queries per client: 125 ``` We ran the script twice. Averages for all queries in each benchmark: | Load | 1 client | 4 clients | 16 clients | | - | - | - | - | | Sakila revenue join, 160 queries | 5.06 / 5.57 s | 4.51 / 2.36 s | 1.59 / 2.94 s | | Generated mixed load, 2,000 queries | 1.17 / 1.25 s | 0.72 / 0.63 s | 0.63 / 0.48 s | The join takes about 31 to 35 ms on one client. More clients help, but the runs vary: 4 clients took 4.5 s in one run and 2.4 s in the next. Run `mysqlslap` several times and compare ranges, not single numbers. ## 4. Connect from your laptop through an SSH tunnel MySQL listens on `127.0.0.1` inside the VM, so forward a local port to it over SSH. `sbx ssh-config` prints an OpenSSH stanza. You can install it into `~/.ssh/config` with `--install`, or save it to its own file and pass that file with `-F`, which leaves your SSH config untouched: ```bash sbx ssh-config > sbx_ssh_config cat sbx_ssh_config ``` ```text # Sandboxer (sbx ssh-config) Host *.sbx User user IdentityFile ~/.ssh/sandboxer_ed25519 IdentitiesOnly yes ProxyCommand sbx proxy %h UserKnownHostsFile ~/.config/sandboxer/known_hosts StrictHostKeyChecking yes ServerAliveInterval 30 ``` The paths in yours are absolute. Connect once with `sbx ssh` so the CLI authorizes its key on the sandbox, then open the tunnel: ```bash sbx ssh $id true ssh -F ./sbx_ssh_config -f -N -o ExitOnForwardFailure=yes -L 13306:127.0.0.1:3306 $id.sbx ``` Any MySQL client on your laptop can now use `127.0.0.1:13306`. We used Python with [PyMySQL](https://pypi.org/project/PyMySQL/) 2.2.8: ```python import statistics, time import pymysql conn = pymysql.connect(host="127.0.0.1", port=13306, user="root", password="pw", database="sakila") with conn.cursor() as cur: cur.execute("SELECT VERSION()") print(cur.fetchone()[0]) times = [] for _ in range(20): t0 = time.perf_counter() cur.execute("SELECT title, rental_rate FROM film WHERE title LIKE %s ORDER BY title LIMIT 3", ("AC%",)) rows = cur.fetchall() times.append((time.perf_counter() - t0) * 1000) print(rows) print(f"median {statistics.median(times):.1f} ms, min {min(times):.1f} ms over {len(times)} queries") ``` ```text 8.4.13 (('ACADEMY DINOSAUR', Decimal('0.99')), ('ACE GOLDFINGER', Decimal('4.99'))) median 154.5 ms, min 154.0 ms over 20 queries ``` Almost all of the 155 ms is the network path from our client to the sandbox and back, which depends on where you are. That is fine for a GUI client or a quick check. For load tests, run the client in the sandbox, as in step 3. Close the tunnel by killing that `ssh` process. [`sbx forward $id --remote 3306`](/docs/ssh#forward-ports) is the CLI's built-in alternative to `ssh -L`. We tested the `ssh -L` route only. ## 5. What survives a stop and resume We inserted a row, stopped the sandbox and resumed it: ```bash sbx exec $id "docker exec mysql mysql -uroot -ppw -e \"INSERT INTO sakila.actor (first_name, last_name) VALUES ('SANDBOX', 'TEST')\"" sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}' docker exec mysql mysql -uroot -ppw -N -e \"SELECT actor_id, first_name, last_name FROM sakila.actor WHERE last_name = 'TEST'\"" ``` ```text mysql Up 3 minutes 201 SANDBOX TEST ``` The stop took 2.2 seconds and the resume 1.4 seconds. The resume reported `memoryRestored: true`, so MySQL never restarted (`Up 3 minutes`). A resume boots from disk instead when no machine with the same CPU type is free, or when you resize. Then the disk is kept and processes start fresh: the Docker volume is there, and the restart policy decides whether MySQL comes back. We didn't force a boot from disk here. Restarting the Docker daemon shows the same policy at work: ```bash sbx exec $id "sudo systemctl restart docker; sleep 1 until docker exec mysql mysql -uroot -ppw -h127.0.0.1 -e 'select 1' >/dev/null 2>&1; do sleep 0.5; done docker ps --format '{{.Names}} {{.Status}}' docker exec mysql mysql -uroot -ppw -N -e \"SELECT actor_id, first_name, last_name FROM sakila.actor WHERE last_name = 'TEST'\"" ``` ```text mysql Up 1 second 201 SANDBOX TEST ``` See [snapshots](/docs/snapshots#resume) for the resume rules. The [pgvector guide](/guides/run-postgres-with-pgvector-in-a-sandbox) forces a boot from disk with a resize and shows a database coming back the same way. ## 6. MariaDB instead The [official MariaDB image](https://hub.docker.com/_/mariadb) works the same way. Its variables start with `MARIADB_`, and the client is `mariadb`. We ran it next to MySQL on host port 3307 in the same sandbox: ```bash sbx exec $id --timeout 300 "docker run -d --name maria --restart unless-stopped \ -e MARIADB_ROOT_PASSWORD=pw -p 127.0.0.1:3307:3306 -v mariadata:/var/lib/mysql mariadb:11.8 until docker exec maria mariadb -uroot -ppw -h127.0.0.1 -e 'select 1' >/dev/null 2>&1; do sleep 0.5; done docker exec maria mariadb -uroot -ppw -N -e 'select version()' docker exec -i maria mariadb -uroot -ppw < sakila-db/sakila-schema.sql docker exec -i maria mariadb -uroot -ppw < sakila-db/sakila-data.sql docker exec -i maria mariadb -uroot -ppw -t < top.sql" ``` ```text 11.8.9-MariaDB-ubu2404 +-----------+---------+---------+ | category | rentals | revenue | +-----------+---------+---------+ | Sports | 1179 | 5314.21 | … ``` MariaDB took 4.9 seconds to accept TCP connections (image already pulled), loaded Sakila without errors and returned the same revenue table. Unlike `mysql:8.4`, the `mariadb:11.8` image ships `mariadb-slap`, so you can benchmark without installing anything. For the tunnel, forward to port 3307 instead of 3306. ## 7. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Why does my first query fail right after `docker run`? On first start, the `mysql:8.4` image runs a temporary server on the socket only, then restarts it with networking. `mysqladmin ping` can answer from the temporary server. Wait until a TCP query with `-h127.0.0.1` succeeds instead; we used the same wait for MariaDB. ### Where is mysqlslap in the MySQL Docker image? The `mysql:8.4` image doesn't include it. Install `mysql-client` from Ubuntu in the sandbox (it ships `mysqlslap` 8.0.46) and connect over TCP to `127.0.0.1:3306`, or use `mariadb-slap`, which is in the `mariadb:11.8` image. ### Can I give MySQL a public URL instead of a tunnel? A [hosted URL](/docs/hosting) is HTTPS, built for web apps and APIs. A MySQL client speaks the MySQL protocol on a TCP port, so we used SSH and didn't test a hosted URL. The tunnel also keeps the database private: MySQL listens only on `127.0.0.1` in the VM. ### Will my data survive if the sandbox auto-stops? Yes. Auto-stop is a normal stop: the disk and memory are saved, and `sbx resume` brings the sandbox back. In our run, MySQL kept running through the stop and the row was there. Keep `--restart unless-stopped` for the times a resume boots from disk. ### MySQL or MariaDB? Use the one you run in production. Both loaded Sakila and returned the same results here. They differ in features and in later versions, so test against the engine and major version your app uses. ## Related - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres) - [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor) - [SSH and file transfer](/docs/ssh) --- # How Do I Run Flowise in a Sandbox on a Private URL? Source: https://sandboxer.com/guides/run-flowise-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the flowiseai/flowise:3.1.3 Docker image in a small Sandboxer sandbox with a volume on /home/node/.flowise, bound to 127.0.0.1:3000, and host it with sbx host --localhost. Create the admin account first. Scripts call the prediction API with a Cookie __sbx_auth header plus a Flowise API key. ## At a glance Everything here ran on one `small` sandbox (2 vCPU, 4 GB, 12 GB disk) on October 27, 2026, in `eu-central`. Single runs, not benchmarks; timings vary by host. No language model was used: the test flow is a JavaScript function, so no provider key was needed. | Step | What we saw | | - | - | | `sbx new --type small` | Ready in 3.2 seconds | | Pull `flowiseai/flowise:3.1.3` | 2 min 14 s, 5.7 GB on disk | | Container start to "Flowise Server is listening" | About 9 seconds | | Memory at idle | About 700 MiB | | Hosted URL with no cookie | `401` from the Sandboxer proxy, including `/api/v1/prediction/…` | | Prediction call from a laptop | `200`, median 278 ms over 10 calls | | `sbx stop --wait`, then `sbx resume` | 2.2 s + 1.4 s, `memoryRestored: true`, same PID, first call `200` in 0.28 s | | `sudo reboot` (cold boot) | Flowise back by itself, prediction `200` 13.7 s after the reboot command | ## Pick the image tag: 3.1.4 crashes with SQLite Flowise's [Docker docs](https://github.com/FlowiseAI/Flowise/tree/main/docker) run `flowiseai/flowise` with data in `/home/node/.flowise`. We first tried the newest tag on October 27, 2026, `3.1.4`. With the default SQLite database it restarted in a loop: ```text [ERROR]: TypeError: this.db.exec is not a function at new SQLiteStore (/usr/local/lib/node_modules/flowise/node_modules/connect-sqlite3/lib/connect-sqlite3.js:56:17) at initializeDBClientAndStore (…/enterprise/middleware/passport/SessionPersistance.js:96:23) ``` That is upstream issue [#6688](https://github.com/FlowiseAI/Flowise/issues/6688): a newer `connect-sqlite3` changed its API. `3.1.3` was built before that change and starts fine, so this page pins it. (Its `/api/v1/version` reports `3.1.2`.) Check the issue before you move to a newer tag. `3.1.4` also runs as the `node` user. A fresh Docker volume is owned by root, so before it reached the SQLite crash it failed with `EACCES: permission denied, mkdir '/home/node/.flowise/storage'`. The `chown` line below fixes that. `3.1.3` runs as root and doesn't need it, but it does no harm. ## Start Flowise `sbx new` prints the sandbox's subdomain. Ours was `hasty-clear-moss`, so the Flowise URL is `https://hasty-clear-moss-3000.box.sandboxer.com`. ```bash sbx new --name flowise --type small id=sb_z5r8i3bdpj # from sbx new sbx exec $id --detach "docker pull flowiseai/flowise:3.1.3 > pull.log 2>&1" sbx exec $id "tail -1 pull.log" # repeat until it prints the image name ``` The pull takes a couple of minutes, which is longer than you want an attached `sbx exec` to wait, so it runs detached. Then save this as `start.sh`, copy it in with `sbx scp start.sh $id:/home/user/start.sh`, and run it with `sbx exec $id "bash start.sh"`: ```bash docker volume create flowise_data docker run --rm --user root --entrypoint chown -v flowise_data:/home/node/.flowise \ flowiseai/flowise:3.1.3 -R node:node /home/node/.flowise docker run -d --name flowise --restart unless-stopped \ -p 127.0.0.1:3000:3000 \ -e PORT=3000 \ -e DATABASE_PATH=/home/node/.flowise \ -e SECRETKEY_PATH=/home/node/.flowise \ -e LOG_PATH=/home/node/.flowise/logs \ -e BLOB_STORAGE_PATH=/home/node/.flowise/storage \ -e APP_URL=https://hasty-clear-moss-3000.box.sandboxer.com \ -e NUMBER_OF_PROXIES=1 \ -e DISABLE_FLOWISE_TELEMETRY=true \ -v flowise_data:/home/node/.flowise \ flowiseai/flowise:3.1.3 until curl -sf 127.0.0.1:3000/api/v1/ping; do sleep 0.5; done ``` ```text pong ``` The four path variables are the ones Flowise's Docker README lists for keeping flows, logs, credentials and uploads. `-p 127.0.0.1:3000:3000` keeps the port off the sandbox's public address. `NUMBER_OF_PROXIES=1` tells Flowise it sits behind one proxy, which is Sandboxer's. ## Host it on a private URL Because the port is bound to localhost, host it with `--localhost` ([Hosting](/docs/hosting#apps-that-listen-on-localhost)): ```bash sbx host $id 3000 --localhost --title flowise ``` ```json {"ok":true,"type":"port.hosted","sandboxId":"sb_z5r8i3bdpj","port":3000,"url":"https://hasty-clear-moss-3000.box.sandboxer.com?_token=…","access":"private","isProtected":true,"localhost":true} ``` Open that full link in a browser once. It answers `302` and sets the `__sbx_auth` cookie, and the Flowise UI loads (`Flowise - Build AI Agents, Visually`). From a script, send the token as a cookie header instead: ```bash B=https://hasty-clear-moss-3000.box.sandboxer.com C="Cookie: __sbx_auth=" # the _token value from sbx host curl -s -o /dev/null -w "%{http_code}\n" $B/api/v1/ping # 401 curl -s -o /dev/null -w "%{http_code}\n" -X POST $B/api/v1/prediction/x # 401 curl -s -H "$C" $B/api/v1/ping # pong curl -s -H "$C" $B/api/v1/chatflows # {"error":"Unauthorized Access"} ``` The last line is Flowise's own login, not Sandboxer's. There are two gates, and the second one has gaps, covered next. ## Flowise's own auth in 3.x `FLOWISE_USERNAME` and `FLOWISE_PASSWORD` are deprecated. Since 3.0.1, Flowise has [email and password accounts](https://docs.flowiseai.com/configuration/authorization/app-level), and the first account registered becomes the owner. On a fresh volume, whoever reaches the URL first can claim it, so do it while the port is private. The UI shows the setup form. The same call from a script: ```bash curl -s -H "$C" -H 'Content-Type: application/json' \ -d '{"user":{"name":"Admin","email":"admin@example.com","credential":""}}' \ $B/api/v1/account/register ``` ```text {"user":{"id":"b7425843-…","name":"Admin","email":"admin@example.com","status":"active",…},"organization":{"id":"f88a244b-…","name":"Default Organization",… ``` A second registration got `400 {"message":"You can only have one organization"}`. Sign in with `POST /api/v1/auth/login` (`{"email","password"}`). Flowise sets `token`, `refreshToken` and `connect.sid` cookies, and its UI sends the header `x-request-from: internal` with them. The gap is the prediction API. Flowise's server allows `/api/v1/prediction/` without a login, so a chatflow with no API key answers anyone who can reach it. On a private Sandboxer URL that means anyone with the token. If you ever make the port public, assign each chatflow an API key first ([chatflow-level auth](https://docs.flowiseai.com/configuration/authorization/chatflow-level)). ## Build a chatflow that needs no model We had no model key, so the test flow is an Agentflow with three nodes: Start, a Custom Function that counts words, and a Direct Reply. You can build the same thing on the canvas. We created it through the API with this script, `make-flow.py`, which writes a minimal flow definition. We didn't open it on the canvas afterwards. ```python import json def node(id, name, label, x, inputs, vars=()): # Flowise only fills {{ }} variables in inputs listed here with acceptVariable params = [{"name": n, "acceptVariable": True} for n in vars] return {"id": id, "type": "agentFlow", "position": {"x": x, "y": 0}, "data": {"id": id, "name": name, "label": label, "inputParams": params, "inputs": inputs}} js = """const q = $flow.input || ''; const words = q.trim().split(/\\s+/).filter(Boolean); return JSON.stringify({ words: words.length, chars: q.length, upper: q.toUpperCase() });""" nodes = [ node("startAgentflow_0", "startAgentflow", "Start", 0, {"startInputType": "chatInput"}), node("customFunctionAgentflow_0", "customFunctionAgentflow", "Text stats", 300, {"customFunctionJavascriptFunction": js}), node("directReplyAgentflow_0", "directReplyAgentflow", "Reply", 600, {"directReplyMessage": "Stats: {{ customFunctionAgentflow_0 }}"}, vars=["directReplyMessage"]), ] edges = [ {"id": "e1", "type": "agentFlow", "source": "startAgentflow_0", "target": "customFunctionAgentflow_0"}, {"id": "e2", "type": "agentFlow", "source": "customFunctionAgentflow_0", "target": "directReplyAgentflow_0"}, ] flow = {"nodes": nodes, "edges": edges} print(json.dumps({"name": "text-stats", "type": "AGENTFLOW", "flowData": json.dumps(flow)})) ``` Two things we learned the hard way. Without `inputParams` the prediction failed with `inputParams is not iterable`. Without `acceptVariable` the reply came back as the literal text `{{ customFunctionAgentflow_0 }}`. Create it, a Flowise API key, and attach the key. Sign in to a cookie jar first, then add `__sbx_auth` to the same jar so one `-b` carries both: ```bash curl -s -c jar.txt -H "$C" -H 'Content-Type: application/json' \ -d '{"email":"admin@example.com","password":""}' $B/api/v1/auth/login printf '#HttpOnly_hasty-clear-moss-3000.box.sandboxer.com\tFALSE\t/\tTRUE\t0\t__sbx_auth\t%s\n' '' >> jar.txt python3 make-flow.py > flow.json H=(-b jar.txt -H 'x-request-from: internal' -H 'Content-Type: application/json') curl -s "${H[@]}" -d @flow.json $B/api/v1/chatflows # {"id":"3752756b-…","type":"AGENTFLOW",…} curl -s "${H[@]}" -d '{"keyName":"laptop","permissions":["chatflows:view"]}' $B/api/v1/apikey curl -s "${H[@]}" -X PUT -d '{"apikeyid":""}' $B/api/v1/chatflows/ ``` Leaving out `permissions` returned `412 permissions must be an array of strings!`. To use a real model without a provider key, swap the Custom Function for an LLM node backed by Ollama on CPU, set up as in [Run Ollama on CPU](/guides/run-ollama-on-cpu-in-a-cloud-sandbox). We didn't test that here. ## Call the chatflow from your laptop ```bash F=3752756b-688b-4890-8353-36ae2d6d1c82 curl -s -H "$C" -H "Authorization: Bearer " \ -H 'Content-Type: application/json' \ -d '{"question":"Flowise runs fine in a sandbox"}' $B/api/v1/prediction/$F ``` ```json {"text":"Stats: {\n \"words\": 6,\n \"chars\": 30,\n \"upper\": \"FLOWISE RUNS FINE IN A SANDBOX\"\n}","question":"Flowise runs fine in a sandbox","chatId":"973f924f-…",…} ``` The proxy strips `__sbx_auth` and passes `Authorization` through to Flowise. Every combination we tried: | Sent | Answer | | - | - | | Nothing | `401`, Sandboxer page | | Flowise API key, no `__sbx_auth` | `401`, Sandboxer page | | `__sbx_auth`, no key or a wrong key | `500 {"message":"Error: predictionsServices.buildChatflow - Unauthorized"}` from Flowise | | `__sbx_auth` + the right key | `200` | Flowise reports a missing key as a `500`, not a `401`, so check the message. Ten calls with both headers took 255 to 323 ms each from the laptop. With `"streaming": true` in the body, the answer comes back as `text/event-stream` through the hosted URL. You get `nextAgentFlow` events per node, a `token` event with the reply, then `end` with `[DONE]`. ## What survives stop, resume and reboot Everything Flowise keeps is in the volume: `database.sqlite` (accounts, flows, API keys, chat history), the generated `encryption.key` and JWT and session secrets, `logs/` and `storage/`. It was 444 KB after this test. ```bash sbx stop $id --wait # 2.2 s sbx resume $id # 1.4 s sbx info $id --json # "memoryRestored": true ``` Same container PID, and the first prediction after resume returned `200` in 0.28 seconds. Our Flowise login cookies from before the stop still worked. For a cold boot we ran `sudo reboot` inside the sandbox. While Flowise was down, the hosted URL returned `502` (nothing listening yet). Docker started at boot, `--restart unless-stopped` brought the container back, and prediction answered `200` 13.7 seconds after the reboot command. The flow, the API key and the admin login were all still there. The old login cookies still worked too, because the JWT secrets live on the volume, not in memory. For why the restart policy matters, see [which way of starting a service survives resume](/guides/run-a-service-that-comes-back-after-resume). ## FAQ ### Do FLOWISE_USERNAME and FLOWISE_PASSWORD still work? They're deprecated in Flowise 3.x. Flowise only uses them to let an older install move to the new account system. On a new install, register the first account, which becomes the owner, and keep the port private until you have. ### Which size does Flowise need? `small` was enough here: about 700 MiB at idle, and the 5.7 GB image fits on its disk. Flows that call a local Ollama model need more memory, so use `default` for those. ### Is the prediction API protected by Flowise's login? No. Flowise lets `/api/v1/prediction/` through without a login. On a private URL the Sandboxer token still protects it. On a public URL, only a chatflow API key does. ### Can a browser embed or chat widget call a private Flowise URL? Only from a browser that has already opened the token link and holds the cookie. For widgets on other sites, you'd need a public port plus chatflow API keys. We didn't test embedding. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run n8n in a cloud sandbox](/guides/run-n8n-in-a-cloud-sandbox) - [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox) - [Hosting](/docs/hosting) --- # How to Run MongoDB in a Cloud Sandbox Source: https://sandboxer.com/guides/run-mongodb-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official mongo:8.0 Docker image in a Sandboxer microVM with a named volume, --restart unless-stopped and a port bound to 127.0.0.1, then connect from your laptop through an ssh -L tunnel. On 29,353 zip code documents, an index on city cut a query from scanning every document to reading 41. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB): MongoDB Community 8.0.34 from the official `mongo:8.0` image, mongosh 2 on the laptop, and the `mongo-express:1` image for a web UI. The data is a public sample file of US zip codes served from `media.mongodb.org` (3.2 MB, one JSON document per line). Timings are single runs or three back-to-back runs; yours will differ. | Step | Result | | - | - | | `sbx new` until ready | 3.2 s | | Pull `mongo:8.0`, start it, wait for `ping` | 11.3 s | | `mongoimport` 29,353 documents (3.2 MB) | 0.4 s | | `find({city: "SPRINGFIELD"})`, no index | COLLSCAN, 29,353 docs examined, 6 to 8 ms | | Build the `city_1` index | 42 ms | | Same query with the index | IXSCAN, 41 keys and 41 docs examined, 0 ms | | Same query from a laptop through `ssh -L` | 157 ms median round trip | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s, memory restored, data and index intact | ## 1. Start MongoDB with a volume and a restart policy Docker is preinstalled in every sandbox, so the [official image](https://hub.docker.com/_/mongo) is one command. Image pulls take longer than the default 30-second `sbx exec` timeout, so pass `--timeout`: ```bash sbx new --name mongo --type default --no-env id=sb_ywu5u688p7 # the id that sbx new printed sbx exec $id --timeout 600 "docker run -d --name mongo --restart unless-stopped \ -p 127.0.0.1:27017:27017 -v mongodata:/data/db mongo:8.0" sbx exec $id "docker exec mongo mongosh --quiet --eval 'db.version()'; docker ps --format '{{.Names}} {{.Image}} {{.Status}}'" ``` ```text 8.0.34 mongo mongo:8.0 Up 6 seconds ``` Three flags matter: - `-v mongodata:/data/db` puts the database files in a named Docker volume on the sandbox disk, so they outlive the container. - `--restart unless-stopped` makes Docker start MongoDB again when the sandbox boots from disk instead of restoring memory. - `-p 127.0.0.1:27017:27017` keeps MongoDB inside the VM. The image starts without authentication, so nothing outside the VM should reach port 27017. You connect through SSH in step 4. ## 2. Import a public dataset with mongoimport The `mongo` image ships with [`mongoimport`](https://www.mongodb.com/docs/database-tools/mongoimport/). Download the file in the sandbox and pipe it into the container: ```bash sbx exec $id --timeout 300 "curl -sSL https://media.mongodb.org/zips.json -o zips.json && wc -l zips.json \ && docker exec -i mongo mongoimport --db geo --collection zips < zips.json" ``` ```text 29353 zips.json 2026-10-27T09:31:06.308+0000 connected to: mongodb://localhost/ 2026-10-27T09:31:06.691+0000 29353 document(s) imported successfully. 0 document(s) failed to import. ``` Each document is one zip code: `{ "_id" : "01001", "city" : "AGAWAM", "loc" : [ -72.622739, 42.070206 ], "pop" : 15338, "state" : "MA" }`. ## 3. Run an aggregation, then index a query Save this as `agg.js`. It totals population and zip codes per state and keeps the top five: ```javascript db = db.getSiblingDB("geo"); db.zips.aggregate([ { $group: { _id: "$state", pop: { $sum: "$pop" }, zips: { $sum: 1 } } }, { $sort: { pop: -1 } }, { $limit: 5 } ]).forEach(d => print(JSON.stringify(d))); ``` Copy it into the container and run it with `mongosh --file`. Piping a script to `mongosh` on stdin also works, but it echoes REPL prompts into the output. ```bash sbx scp ./agg.js $id:/home/user/ sbx exec $id "docker cp agg.js mongo:/tmp/ && docker exec mongo mongosh --quiet --file /tmp/agg.js" ``` ```text {"_id":"CA","pop":29754890,"zips":1516} {"_id":"NY","pop":17990402,"zips":1595} {"_id":"TX","pop":16984601,"zips":1671} {"_id":"FL","pop":12686644,"zips":804} {"_id":"PA","pop":11881643,"zips":1458} ``` Now measure a lookup by city. Save as `explain.js`. It prints the plan and the counters from [`explain("executionStats")`](https://www.mongodb.com/docs/manual/reference/explain-results/): ```javascript db = db.getSiblingDB("geo"); const s = db.zips.find({ city: "SPRINGFIELD" }).explain("executionStats"); const e = s.executionStats, w = s.queryPlanner.winningPlan; print(`plan=${w.stage}/${w.inputStage ? w.inputStage.stage : "-"} returned=${e.nReturned} ` + `keysExamined=${e.totalKeysExamined} docsExamined=${e.totalDocsExamined} ms=${e.executionTimeMillis}`); ``` Without an index, MongoDB reads every document to find 41 matches: ```text plan=COLLSCAN/- returned=41 keysExamined=0 docsExamined=29353 ms=7 plan=COLLSCAN/- returned=41 keysExamined=0 docsExamined=29353 ms=6 plan=COLLSCAN/- returned=41 keysExamined=0 docsExamined=29353 ms=8 ``` Create the index (`index.js`): ```javascript db = db.getSiblingDB("geo"); const t0 = Date.now(); print(db.zips.createIndex({ city: 1 })); print(`build ms=${Date.now() - t0}`); ``` ```text city_1 build ms=42 ``` Run `explain.js` again. The plan is now an index scan that reads only the 41 matching keys and documents: ```text plan=FETCH/IXSCAN returned=41 keysExamined=41 docsExamined=41 ms=0 plan=FETCH/IXSCAN returned=41 keysExamined=41 docsExamined=41 ms=0 plan=FETCH/IXSCAN returned=41 keysExamined=41 docsExamined=41 ms=0 ``` `executionTimeMillis` rounds to whole milliseconds, so at this size the useful number is `docsExamined`: 29,353 before, 41 after. That ratio is what grows with your collection. ## 4. Connect mongosh or Compass from your laptop MongoDB listens on `127.0.0.1` in the VM, so forward a local port to it over SSH. Print the CLI's SSH stanza and save it to a file. You can also paste it into `~/.ssh/config`, or let `sbx ssh-config --install` append it. ```bash sbx ssh-config > ~/.ssh/sbx.conf cat ~/.ssh/sbx.conf ``` ```text # Sandboxer (sbx ssh-config) Host *.sbx User user IdentityFile ~/.ssh/sandboxer_ed25519 IdentitiesOnly yes ProxyCommand sbx proxy %h UserKnownHostsFile ~/.config/sandboxer/known_hosts StrictHostKeyChecking yes ServerAliveInterval 30 ``` The paths in your copy are absolute; we shortened them here. Connect once with `sbx ssh` so the CLI authorizes its key on the sandbox, then open the tunnel. We used local port 27018 so it can't clash with a MongoDB on your laptop: ```bash sbx ssh $id true ssh -F ~/.ssh/sbx.conf -f -N -o ExitOnForwardFailure=yes -L 27018:127.0.0.1:27017 $id.sbx mongosh --quiet "mongodb://127.0.0.1:27018/geo?directConnection=true" \ --eval 'print(db.version()); print(db.zips.countDocuments()); printjson(db.zips.findOne({_id: "10001"}))' ``` ```text 8.0.34 29353 { _id: '10001', city: 'NEW YORK', loc: [ -73.996705, 40.74838 ], pop: 18913, state: 'NY' } ``` MongoDB Compass and any driver take the same connection string: `mongodb://127.0.0.1:27018/?directConnection=true`. We tested mongosh only. To see what the network adds, we ran the indexed `SPRINGFIELD` query 20 times from the laptop (`lat.js`, run with `mongosh … --file lat.js`): ```javascript const t = []; for (let i = 0; i < 20; i++) { const t0 = Date.now(); db.zips.find({ city: "SPRINGFIELD" }).toArray(); t.push(Date.now() - t0); } t.sort((a, b) => a - b); print(`median ${t[10]} ms, min ${t[0]} ms over ${t.length} queries`); ``` ```text median 157 ms, min 155 ms over 20 queries ``` The query takes under a millisecond in the VM. The rest is the round trip between our client and the sandbox, which depends on where you are. For load tests or code that queries in a loop, run the client in the sandbox too. [`sbx forward $id --remote 27017`](/docs/ssh#forward-ports) is the CLI's built-in alternative to `ssh -L`; we tested the `ssh -L` route only. ## 5. Optional: Mongo Express on a private URL [Mongo Express](https://github.com/mongo-express/mongo-express) is a web UI for browsing collections. Put it on a Docker network with MongoDB, keep its basic auth on, and give it a hosted URL: ```bash sbx exec $id --timeout 600 "docker network create mongonet && docker network connect mongonet mongo && \ docker run -d --name mongo-express --network mongonet --restart unless-stopped -p 8081:8081 \ -e ME_CONFIG_MONGODB_URL=mongodb://mongo:27017 \ -e ME_CONFIG_BASICAUTH_USERNAME=admin -e ME_CONFIG_BASICAUTH_PASSWORD=change-me mongo-express:1" sbx host $id 8081 --title "Mongo Express" ``` [`sbx host`](/docs/hosting) prints a private URL like `https://simple-chief-easel-8081.box.sandboxer.com?_token=…`. Treat it like a password. There are two locks, and we checked each with `curl`: ```bash url='https://simple-chief-easel-8081.box.sandboxer.com?_token=…' # the full URL sbx host printed base=${url%%\?*} curl -s -o /dev/null -w '%{http_code}\n' "$base/" # no token curl -s -c jar -b jar -L -o /dev/null -w '%{http_code}\n' "$url" # token, no login curl -s -c jar -b jar -L -o /dev/null -w '%{http_code}\n' -u admin:change-me "$url" # token and login ``` ```text 401 401 200 ``` The first `401` comes from Sandboxer: no token, so the request never reaches the sandbox. The second comes from Mongo Express itself (`www-authenticate: Basic realm="Authorization Required"`): the token was accepted and its cookie set, but there was no password. With both, the page title is `Home - Mongo Express` and the `geo` database lists `zips`. Change `change-me` before you use this for anything real. ## 6. What survives a stop and resume We wrote one more document, flushed to disk, then stopped and resumed: ```bash sbx exec $id "docker exec mongo mongosh --quiet geo --eval 'db.notes.insertOne({msg: \"written before stop\", at: new Date()}); printjson(db.adminCommand({fsync: 1}).ok)'" sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'; docker exec mongo mongosh --quiet geo \ --eval 'print(db.zips.countDocuments()); print(db.zips.getIndexes().map(i => i.name).join(\",\")); print(db.notes.findOne().msg)'" ``` ```text mongo-express Up 37 seconds mongo Up 4 minutes 29353 _id_,city_1 written before stop ``` The stop took 2.2 seconds and the resume 1.4 seconds. The resume reported `memoryRestored: true`, so MongoDB was never restarted (`Up 4 minutes`). The hosted Mongo Express URL answered `200` again with the same token. Two things did not carry over: - **The SSH tunnel.** The `ssh -L` process on the laptop exited when the sandbox stopped. Run the same `ssh -F … -L …` line again after the resume; the new tunnel returned the `notes` document. - **Memory, sometimes.** A resume boots from disk instead when no machine with the same CPU type is free, or when you move to a bigger size. Then running processes are gone and Docker's restart policy starts both containers again from the `mongodata` volume. We did not force that case in this run; the [pgvector guide](/guides/run-postgres-with-pgvector-in-a-sandbox) shows the same pattern for Postgres. Moving to a smaller size isn't supported yet; create a new sandbox and copy the data with `mongodump` and `mongorestore`. ## 7. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Do I need authentication on MongoDB in a sandbox? The official image starts without it unless you set `MONGO_INITDB_ROOT_USERNAME` and `MONGO_INITDB_ROOT_PASSWORD` (see the [image docs](https://hub.docker.com/_/mongo)). In this guide, port 27017 is bound to `127.0.0.1` and reached only through SSH, which takes your API key. If you ever host the port or bind it to all interfaces, set a root user first. ### Why does `mongosh` print `test>` and `geo>` prompts in my output? You piped the script into `mongosh` on stdin, which runs it as an interactive session. Copy the file into the container and use `mongosh --quiet --file /tmp/script.js` instead, as in step 3. ### Can I reach MongoDB on a hosted HTTPS URL? A [hosted URL](/docs/hosting) serves HTTP. MongoDB clients speak MongoDB's own wire protocol over TCP, so we used an SSH tunnel and did not test a hosted URL for port 27017. Mongo Express is a web app, so it works on a hosted URL, as in step 5. ### Can I run a replica set for change streams or transactions? Change streams and multi-document transactions need a replica set. You can start `mongod` with `--replSet` and run `rs.initiate()`, but we did not test that here. Follow MongoDB's [replica set deployment docs](https://www.mongodb.com/docs/manual/tutorial/deploy-replica-set/). ### Will my data survive an auto-stop? Yes. Auto-stop is a normal stop: the disk and memory are saved and `sbx resume` brings the sandbox back. The data lives in the `mongodata` volume on the sandbox disk, and the restart policy covers a resume that boots from disk. ## Related - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [SSH and file transfer](/docs/ssh) --- # How to Run code-server (VS Code in the Browser) in a Cloud Sandbox Source: https://sandboxer.com/guides/run-code-server-vs-code-in-the-browser. Updated 2026-10-27. **Short answer:** Install code-server with its official install script, set bind-addr 0.0.0.0:8080 in its config, enable the code-server@user systemd service, then run sbx host 8080. The private hosted URL's token gates the editor, terminal WebSockets included, so auth none is safe as long as the port stays private. ## What we ran Everything below ran on prod on 2026-10-27, on one `small` sandbox (2 vCPU, 4 GB), with code-server 4.143.1 (Code 1.143.1). Tokens and cookies are replaced with `` and ``. If you want your desktop VS Code or Cursor instead of a browser tab, see [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor). This page is for a full editor in any browser, with nothing installed on your computer. | Step | Result | | - | - | | Install with the official script | 7.2 s | | Editor page through the private URL | `200` in 0.23 s, workbench drawn in 4.3 s in headless Chromium | | Request without the token | `401`, for the page and for the WebSocket handshake | | Terminal in the browser | Worked over the hosted URL's WebSockets | | Extension installed from the CLI | `esbenp.prettier-vscode@12.4.0` | | After `sbx stop --wait` and `sbx resume` | Same process, same extension, URL answered `200` at once | | Cold boot of a fork (disk only) | Service started on its own, extension kept | ## 1. Install code-server code-server's [install docs](https://coder.com/docs/code-server/install) recommend its install script, which picks the package for your distribution. On a Sandboxer sandbox (Ubuntu, x86_64) it installs the `.deb`: ```bash sbx new --name code-server --type small sbx exec sb_wdrcyuwaak "curl -fsSL https://code-server.dev/install.sh | sh" ``` ```text + sudo dpkg -i ~/.cache/code-server/code-server_4.143.1_amd64.deb … deb package has been installed. To have systemd start code-server now and restart on boot: sudo systemctl enable --now code-server@$USER … ``` `code-server --version` printed `4.143.1 22ddf60dbc52f164cff13620ab515086dd08c90a with Code 1.143.1`. The whole install took 7.2 seconds. ## 2. Configure it and run it as a service code-server reads `~/.config/code-server/config.yaml`. By default it listens on `127.0.0.1:8080` with a random password. Bind it to all interfaces so the hosting proxy can reach it, and turn its own login off, because the hosted URL's token does that job (next section): ```bash sbx exec sb_wdrcyuwaak 'cat > ~/.config/code-server/config.yaml < ``` A hosted port is private by default ([Hosting](/docs/hosting)). Without the token, the Sandboxer proxy answers and code-server never sees the request: ```bash curl -si https://hardy-lofty-seal-8080.box.sandboxer.com/ | head -1 ``` ```text HTTP/2 401 ``` The `401` page is titled "Access token required". With the token, the proxy sets a 30-day cookie and redirects to `/`, and the editor loads: ```text HTTP/2 302 location: / set-cookie: __sbx_auth=; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax ``` ```text GET / with the cookie -> 200 in 0.23 s ``` The HTML has no static ``. VS Code sets it from script, so we opened the token link in headless Chromium (Playwright) and read it once the workbench was drawn: ```text first response: 200 workbench visible after 4304 ms final URL: https://hardy-lofty-seal-8080.box.sandboxer.com/ document.title: Welcome - code-server ``` ## 4. The terminal and WebSockets The workbench talks to the server over WebSockets, and the integrated terminal runs on them. In the same browser session we opened **Terminal: Create New Terminal** from the Command Palette and typed a command. It ran in the sandbox: ```bash # typed into the browser terminal echo term-ok from $(hostname) > /tmp/term-ok ``` ```bash sbx exec sb_wdrcyuwaak "cat /tmp/term-ok" ``` ```text term-ok from hardy-lofty-seal ``` Playwright saw two WebSockets, both carrying traffic through the hosted URL: ```text websocket: wss://hardy-lofty-seal-8080.box.sandboxer.com/stable-22ddf60d…?reconnectionToken=… frames received: 272 (open) websocket: wss://hardy-lofty-seal-8080.box.sandboxer.com/stable-22ddf60d…?reconnectionToken=… frames received: 62 (open) ``` This works because the browser sends the `__sbx_auth` cookie on the WebSocket handshake. A handshake to the same path without the cookie got `401` from the proxy. So the token gates the whole editor, not only the first page. ## auth none or code-server's password? We tested both behind the private URL. Both work, WebSockets and terminal included. | | `auth: none` | `auth: password` | | - | - | - | | Who can get in | Anyone with the token link or its cookie | Anyone with the token link **and** the password | | First visit | Editor opens | `302` to `./login`, page titled "code-server login" | | After login | | `302` to `./`, sets a `code-server-session` cookie, editor opens | | Terminal over WebSockets | Worked | Worked | | Use it when | It's just you, and the port stays private | You share the link, or want a second factor if the link leaks | The code-server docs list `none` as an auth option and warn against exposing it unprotected ([FAQ](https://coder.com/docs/code-server/FAQ)). On Sandboxer, the private hosted URL is that protection. Two rules follow: - **Never run `sbx host … --public` on a code-server with `auth: none`.** The editor's terminal is a shell as `user`, with `sudo`. A public URL would give it to anyone. - **Treat the token link and cookie like a password.** To cut off everyone who has it, switch the port public and back to private, which mints a new token, or take it down with `sbx host hide` (hosting it again brings the old link back, so hide alone doesn't revoke it). Do the switch with code-server stopped or on `auth: password`. For password mode, set `auth: password` and `password: <something long>` in the same config file and run `sudo systemctl restart code-server@user`. ## 5. Install an extension from the CLI ```bash sbx exec sb_wdrcyuwaak --timeout 300 "code-server --install-extension esbenp.prettier-vscode; code-server --list-extensions --show-versions" ``` ```text Installing extensions... Installing extension 'esbenp.prettier-vscode'... Extension 'esbenp.prettier-vscode' v12.4.0 was successfully installed. esbenp.prettier-vscode@12.4.0 ``` code-server installs extensions from [Open VSX](https://open-vsx.org/), not Microsoft's marketplace ([FAQ](https://coder.com/docs/code-server/FAQ)), so search there for the ID. Extensions go to `~/.local/share/code-server/extensions` on the sandbox disk. On our first try the command stalled on a slow Open VSX request and hit the `exec` time limit. A retry finished in 1.4 seconds. Pass `--timeout` to `sbx exec` for long installs. ## 6. Stop and resume ```bash sbx stop sb_wdrcyuwaak --wait curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt https://hardy-lofty-seal-8080.box.sandboxer.com/ ``` ```text 404 ``` While the sandbox is stopped, the URL answers `404`. Then: ```bash sbx resume sb_wdrcyuwaak ``` The resume returned `"memoryRestored": true` in 1.4 seconds. The URL answered `200` with the same cookie from before the stop, and nothing had restarted: ```text active MainPID=3644 # same PID as before the stop esbenp.prettier-vscode@12.4.0 ``` Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk (`memoryRestored: false`): files, extensions and hosted routes are kept, and processes start fresh. The enabled systemd unit covers that case. To test a cold boot, we forked the sandbox, because a fork copies the disk only. On the fork, code-server was already running with a new PID, and the extension and project files were there: ```bash sbx fork sb_wdrcyuwaak --wait --type small sbx exec sb_5k0lnk4n7j "systemctl is-active code-server@user; systemctl show -p MainPID code-server@user; code-server --list-extensions --show-versions" ``` ```text active MainPID=192 esbenp.prettier-vscode@12.4.0 ``` A fork has its own subdomain and no hosted ports, so run `sbx host <fork-id> 8080` to get a URL for it. ## FAQ ### Do I need code-server's password if the URL is private? No, if you keep the port private and the link to yourself. The proxy returns `401` to any request without the token or its cookie, WebSocket handshakes included. Add `auth: password` if you share the link. ### Why does the page say "Nothing is answering on port 8080"? code-server is stopped, or still on its default `bind-addr: 127.0.0.1:8080`. Set `0.0.0.0:8080` and restart the service, or keep localhost and host the port with `--localhost`. ### Can I use Microsoft's extensions, like Pylance? code-server uses Open VSX. Its FAQ explains why Microsoft's marketplace isn't available to it. Look for each extension on Open VSX first. ### Which size should I pick? We ran it on `small` (2 vCPU, 4 GB). If language servers or builds in the terminal need more, `default` has 4 vCPU and 8 GB. ## Related - [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How to Run Redpanda (Kafka API) in a Cloud Sandbox Source: https://sandboxer.com/guides/run-redpanda-kafka-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official Redpanda image in a Sandboxer microVM with two listeners, one for containers and one advertised as localhost:19092, then reach it from your laptop with sbx forward. Kafka is TCP, so it doesn't go through a hosted HTTPS URL; Redpanda Console does. rpk produced 100,000 messages in about 0.2 seconds. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB). [Redpanda](https://docs.redpanda.com/current/get-started/quick-start/) is a single-binary broker that speaks the Kafka protocol, so Kafka clients and tools work against it unchanged. The `latest` image was Redpanda v26.2.6. The laptop client was Python `confluent-kafka` 2.16.0. Timings are single runs or three back-to-back runs; yours will differ. | Step | Result | | - | - | | `sbx new` until ready | 3.2 s | | Pull Redpanda, start it, wait until `rpk cluster health` is healthy | 8 s | | Produce 100,000 short messages with `rpk` inside the sandbox | 187 to 222 ms | | Consume the same 100,001 messages with `rpk` | 174 ms | | Produce one message from the laptop through `sbx forward` and wait for the ack | 125 ms median, 20 messages | | Redpanda Console on a private hosted URL | 401 without the token, 200 with it | | Kafka client pointed at a hosted URL | `Broker transport failure` | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s, memory restored, messages intact | | Memory in use after the tests | Redpanda 584 MiB, Console 44 MiB | ## Which route reaches which port Kafka clients speak Kafka's binary protocol over TCP. A [hosted URL](/docs/hosting) is an HTTPS proxy that checks a token on each request, so it suits web UIs and HTTP APIs, not the broker. | What | Port in the VM | How you reach it from outside | | - | - | - | | Kafka API (producers, consumers, admin) | 19092 on `127.0.0.1` | `sbx forward` or `ssh -L`, both over your API key | | Kafka API for other containers | 9092 on the Docker network | Only other containers on `kafkanet` | | Redpanda Console (web UI) | 8080 on `127.0.0.1` | `sbx host --localhost`, private by default | We checked the second-to-last row of the results table directly: we hosted 19092, pointed a Kafka admin client at `…-19092.box.sandboxer.com:443` over TLS, and it failed with `Failed to get metadata: Local: Broker transport failure`. A plain `curl` at that URL got `401` from the proxy. We then hid the route. ## 1. Start Redpanda with two listeners Docker is preinstalled in every sandbox. The command follows Redpanda's single-broker quickstart. Image pulls take longer than the default 30-second `sbx exec` timeout, so pass `--timeout`: ```bash sbx new --name redpanda --type default --no-env id=sb_pgzo691h33 # the id that sbx new printed sbx exec $id --timeout 600 "docker network create kafkanet && \ docker run -d --name redpanda --network kafkanet --restart unless-stopped \ -p 127.0.0.1:19092:19092 -v redpanda-data:/var/lib/redpanda/data \ docker.redpanda.com/redpandadata/redpanda:latest \ redpanda start --mode dev-container --smp 1 \ --kafka-addr internal://0.0.0.0:9092,external://0.0.0.0:19092 \ --advertise-kafka-addr internal://redpanda:9092,external://localhost:19092 && \ until docker exec redpanda rpk cluster health | grep -q 'Healthy:.*true'; do sleep 1; done && \ docker exec redpanda rpk cluster health" ``` ```text CLUSTER HEALTH OVERVIEW ======================= Healthy: true Unhealthy reasons: [] Controller ID: 0 All nodes: [0] … ``` The listener flags are the part that trips people up. A Kafka client only uses the address you give it to fetch metadata. After that it connects to whatever address the broker *advertises*. So each listener advertises the name its clients can resolve: - `internal://redpanda:9092` is for containers on the `kafkanet` network, such as Redpanda Console in step 4. - `external://localhost:19092` is for clients that reach port 19092 as `localhost`. That covers anything in the VM and your laptop through a port forward that keeps the same port number. Three other flags matter: - `-p 127.0.0.1:19092:19092` keeps the broker inside the VM. Dev-container mode has no authentication, so nothing outside the VM should reach it except through your forward. - `-v redpanda-data:/var/lib/redpanda/data` keeps the log segments in a named volume on the sandbox disk. - `--restart unless-stopped` makes Docker start Redpanda again if the sandbox boots from disk instead of restoring memory. `--mode dev-container` and `--smp 1` are Redpanda's settings for a small development broker. They are not a production layout. See Redpanda's [production deployment docs](https://docs.redpanda.com/current/deploy/) for that. ## 2. Produce and consume with rpk The image ships with [`rpk`](https://docs.redpanda.com/current/reference/rpk/), Redpanda's CLI. Create a topic with three partitions, then send and read one message: ```bash sbx exec $id "docker exec redpanda rpk topic create orders -p 3 && \ echo hello-from-rpk | docker exec -i redpanda rpk topic produce orders -k first && \ docker exec redpanda rpk topic consume orders -n 1" ``` ```text TOPIC STATUS orders OK Produced to partition 1 at offset 0 with timestamp 1793095742303. { "topic": "orders", "key": "first", "value": "hello-from-rpk", "timestamp": 1793095742303, "partition": 1, "offset": 0 } ``` `rpk topic produce` sends one message per input line, so a file of 100,000 lines is a quick load test: ```bash sbx exec $id --timeout 300 "seq 1 100000 | sed 's/^/order-/' > msgs.txt && \ s=\$(date +%s%N); docker exec -i redpanda rpk topic produce orders < msgs.txt > /dev/null; \ echo produce ms=\$(( (\$(date +%s%N)-s)/1000000 )); \ s=\$(date +%s%N); docker exec redpanda rpk topic consume orders -n 100001 -f '%v\n' | tail -1; \ echo consume ms=\$(( (\$(date +%s%N)-s)/1000000 )); \ docker exec redpanda rpk topic describe orders -p" ``` ```text produce ms=222 order-93172 consume ms=174 PARTITION LEADER EPOCH REPLICAS LOG-START-OFFSET HIGH-WATERMARK 0 0 1 [0] 0 38790 1 0 1 [0] 0 38861 2 0 1 [0] 0 22350 ``` Three more produce runs into a fresh topic took 187, 210 and 210 ms. The messages are a few bytes each, so this measures per-message overhead, not disk bandwidth. The last message consumed is `order-93172`, not `order-100000`: Kafka orders messages within a partition, not across a topic. ## 3. Produce and consume from your laptop Open a forward in one terminal. It runs in the foreground until you press Ctrl-C: ```bash sbx forward $id --remote 19092 ``` ```text {"event":"forwarding","id":"sb_pgzo691h33","reverse":false,"local":19092,"remote":19092,"bind":"127.0.0.1"} ``` Keep the local port at 19092. The broker advertises `localhost:19092`, so after the first metadata request your client connects to that address. If you forward to another local port, the client still dials 19092 after the first request (we kept 19092 and did not test other ports). In a second terminal, any Kafka client works with `bootstrap.servers=127.0.0.1:19092`. We used [`confluent-kafka`](https://docs.confluent.io/kafka-clients/python/current/overview.html) for Python (`pip install confluent-kafka`). This script, `laptop.py`, sends 20 messages one at a time, waits for each ack, then reads the whole topic back: ```python import time, uuid from confluent_kafka import Producer, Consumer conf = {"bootstrap.servers": "127.0.0.1:19092"} p = Producer(conf) lat = [] for i in range(20): t0 = time.perf_counter() p.produce("orders", key=f"k{i}", value=f"from-laptop-{i}") p.flush() lat.append((time.perf_counter() - t0) * 1000) lat.sort() print(f"produce+ack median {lat[10]:.0f} ms, min {lat[0]:.0f} ms over {len(lat)} messages") c = Consumer({**conf, "group.id": f"laptop-{uuid.uuid4().hex[:6]}", "auto.offset.reset": "earliest"}) c.subscribe(["orders"]) n, last = 0, None deadline = time.time() + 20 while time.time() < deadline: m = c.poll(1.0) if m is None: if n: break continue if m.error(): print(m.error()); continue n += 1; last = m print(f"consumed {n} messages, last: partition={last.partition()} offset={last.offset()} value={last.value().decode()}") c.close() ``` ```text produce+ack median 125 ms, min 125 ms over 20 messages consumed 100021 messages, last: partition=2 offset=22356 value=from-laptop-17 ``` The 125 ms is the round trip between our client and the sandbox, plus the broker's ack. It depends on where you are. A producer that batches, which is the default when you don't `flush()` after every message, pays it once per batch. For load tests, run the producer in the sandbox as in step 2. [`ssh -L 19092:127.0.0.1:19092 $id.sbx`](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor) is the same tunnel with plain OpenSSH; we tested `sbx forward` only for Kafka. CLI builds from 2026-10-27 (`7e848c3`) on free local port 19092 when you stop `sbx forward` with `kill` from a script, as well as with Ctrl-C. With an older CLI, check the port afterwards (`lsof -i :19092`), because its `ssh` child could keep listening. ## 4. Redpanda Console on a private URL [Redpanda Console](https://docs.redpanda.com/current/console/) is a web UI for topics, messages and consumer groups. It reaches the broker on the internal listener and serves HTTP on 8080, so it fits a hosted URL: ```bash sbx exec $id --timeout 600 "docker run -d --name console --network kafkanet --restart unless-stopped \ -p 127.0.0.1:8080:8080 -e KAFKA_BROKERS=redpanda:9092 docker.redpanda.com/redpandadata/console:latest" sbx host $id 8080 --localhost --title "Redpanda Console" ``` `-p 127.0.0.1:8080:8080` keeps Console off the VM's public IPv6 address whatever the firewall does. (In our October 27, 2026 check, `ufw` did block a Docker-published port there, but Docker can bypass `ufw` on other setups.) `--localhost` tells the hosted URL to relay to that loopback port. (Our test run published `-p 8080:8080` and hosted it without `--localhost`; the results below come from that run.) `sbx host` prints a private URL like `https://zesty-spry-nectar-8080.box.sandboxer.com?_token=…`. Console has no login in this setup, so the token is the only lock. Treat the URL like a password. We checked it with `curl`: ```bash url='https://zesty-spry-nectar-8080.box.sandboxer.com?_token=…' # the full URL sbx host printed base=${url%%\?*} curl -s -o /dev/null -w '%{http_code}\n' "$base/" # no token curl -s -c jar -b jar -L -o /dev/null -w '%{http_code}\n' "$url" # token curl -s -b jar "$base/api/topics" | python3 -c 'import sys, json for t in json.load(sys.stdin)["topics"]: print(t["topicName"], t["partitionCount"], t["isInternal"])' ``` ```text 401 200 __consumer_offsets 3 True orders 3 False ``` The `401` comes from Sandboxer: without the token, the request never reaches the sandbox. With it, the page title is `Redpanda Console` and the topic list includes `orders` and the consumer group offsets from step 3. Don't use `sbx host --public` for Console unless you add Console's own authentication; anyone with the link could browse your messages. ## 5. What survives a stop and resume We wrote a marker message, stopped the sandbox, resumed it and read the marker back: ```bash sbx exec $id "echo written-before-stop | docker exec -i redpanda rpk topic produce orders -k marker" sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'; \ docker exec redpanda rpk topic consume orders -n 100022 -f '%k %v\n' | grep marker" ``` ```text Produced to partition 1 at offset 38865 with timestamp 1793095839469. console Up About a minute redpanda Up About a minute marker written-before-stop ``` Our first stop and resume took 2.2 and 1.4 seconds. The resume reported `memoryRestored: true`, so neither container restarted. The Console URL kept working with the same token. A new `sbx forward` after the resume ran `laptop.py` again with the same 125 ms median; [the docs](/docs/ssh#forward-ports) say a running forward reconnects by itself when the sandbox moves. A resume boots from disk instead when no machine with the same CPU type is free, or when you move to a bigger size. Then processes are gone, and `--restart unless-stopped` starts both containers again from the `redpanda-data` volume. We did not force that case here; the [Docker Compose guide](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) shows the pattern. Moving to a smaller size isn't supported yet; create a new sandbox and copy the topics over with a Kafka mirroring tool. ## 6. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Can a Kafka client connect to a hosted HTTPS URL? No. A hosted URL is an HTTPS proxy that checks a token on each request, and Kafka clients speak their own binary protocol. We tried it and got `Broker transport failure`. Use `sbx forward` or an SSH tunnel for the broker, and a hosted URL for HTTP tools like Redpanda Console. ### Why does my client connect, then fail with "connection refused" or "unknown host redpanda"? Usually the client reached the bootstrap address, then dialed the advertised address from the metadata. We didn't reproduce this error; it follows from how Kafka clients use advertised listeners. Make sure the listener you reach advertises an address your client can resolve: `localhost:19092` for the forward, `redpanda:9092` for containers on the Docker network. Forward to the same local port the broker advertises. ### Is this real Kafka? It is Redpanda, which implements the Kafka API, so Kafka clients and `rpk` work against it. We tested `rpk` and Python `confluent-kafka`. If you need Apache Kafka itself, run the official `apache/kafka` image with the same listener and port-forward pattern; we did not test it here. ### How much memory does it need? After the tests, Redpanda used 584 MiB and Console 44 MiB in a `default` sandbox with 8 GB. Dev-container mode with `--smp 1` uses one core. If a container runs out of memory, it exits with code 137 and the sandbox stays up. ### Can other sandboxes reach my broker? No. Sandboxes can't reach each other's private addresses, and the broker port is bound to `127.0.0.1` in the VM. See [what can reach a sandbox](/guides/what-can-reach-a-sandbox-and-what-it-can-reach). ## Related - [Run RabbitMQ in a sandbox](/guides/run-rabbitmq-in-a-sandbox) - [What can reach a sandbox, and what it can reach](/guides/what-can-reach-a-sandbox-and-what-it-can-reach) - [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor) - [Hosting](/docs/hosting) --- # How to Run JupyterLab on an HTTPS URL in a Cloud Sandbox Source: https://sandboxer.com/guides/run-jupyterlab-on-an-https-url. Updated 2026-10-27. **Short answer:** Install JupyterLab with uv in a venv, run it as an enabled systemd service on 0.0.0.0:8888 with its own token turned off, and host the port with sbx host. The private hosted URL's token then guards the Lab page, the REST API and the kernel websocket. Notebooks and running kernels survived sbx stop --wait and sbx resume. This guide is about the JupyterLab web UI, for people. If you want a headless kernel that an LLM app sends code to, see [Build a stateful code interpreter with a Jupyter kernel](/guides/build-a-stateful-code-interpreter-with-jupyter). ## What we ran Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB): JupyterLab 4.6.4, Python 3.12.3, uv 0.13.1. Timings are single runs. | Step | Result | | - | - | | Create the venv and install JupyterLab with uv | Under 5 s | | Hosted URL without its token | `401`, for `/lab` and `/api/status` | | Hosted URL with its token | `302` that sets a cookie, then `/lab` returns `200` | | Start a kernel over REST and run a cell over the websocket | 0.96 s, from our laptop | | `%pip install` from a notebook kernel | Works if the venv was made with `--seed` | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s. Memory restored: same kernel, same PID, variables kept | | Resume that boots from disk | systemd restarted JupyterLab; `/api/status` answered on the first poll. Notebooks kept, kernels gone | A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. ## 1. Install JupyterLab with uv The base image has Python 3.12 and [uv](https://docs.astral.sh/uv/pip/environments/). JupyterLab isn't preinstalled. Put it in its own venv: ```bash sbx new --name jupyterlab --type small id=sb_dw3osvm3vw # the id that sbx new printed sbx exec $id --timeout 600 "uv venv --seed ~/jlab --python 3.12 -q && \ VIRTUAL_ENV=~/jlab uv pip install -q jupyterlab && \ ~/jlab/bin/jupyter lab --version && ~/jlab/bin/pip --version" ``` ```text 4.6.4 pip 26.2.2 from /home/user/jlab/lib/python3.12/site-packages/pip (python 3.12) ``` `--seed` puts pip into the venv. A uv venv has no pip by default, and the notebook's `%pip` magic needs it. Without `--seed`, this is what a notebook cell printed: ```text /home/user/jlab/bin/python: No module named pip ``` `sbx exec` stops a command after 30 seconds by default. `--timeout 600` gives a slow install room. ## 2. Run it as a systemd service A process you start by hand is lost when a sandbox boots from disk. An enabled systemd service starts again on its own ([long-running docs](/docs/long-running#services-that-should-always-run)). ```bash sbx exec $id "mkdir -p ~/notebooks && sudo tee /etc/systemd/system/jupyterlab.service >/dev/null <<EOF [Unit] Description=JupyterLab After=network-online.target [Service] User=user WorkingDirectory=/home/user/notebooks ExecStart=/home/user/jlab/bin/jupyter lab --no-browser --ip=0.0.0.0 --port=8888 --IdentityProvider.token= Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload && sudo systemctl enable --now jupyterlab && sleep 4 && \ systemctl is-active jupyterlab && curl -s -o /dev/null -w '%{http_code}\n' localhost:8888/api/status" ``` ```text active 200 ``` - `--ip=0.0.0.0` lets the hosting proxy reach JupyterLab on the sandbox's address. - `--IdentityProvider.token=` (empty) turns off JupyterLab's own login token. The hosted URL's token does that job in step 3. The option is described in the [Jupyter Server security docs](https://jupyter-server.readthedocs.io/en/latest/operators/security.html). - `~/notebooks` is the folder the file browser opens. Your notebooks live on the sandbox disk. ## 3. Host the port on a private URL ```bash sbx host $id 8888 --title JupyterLab ``` ```text https://rosy-zany-sequoia-8888.box.sandboxer.com?_token=<token> ``` A hosted port is private by default ([Hosting](/docs/hosting#private-and-public)). Without the token, the proxy answers and JupyterLab never sees the request: ```bash curl -s -o /dev/null -w "%{http_code}\n" https://rosy-zany-sequoia-8888.box.sandboxer.com/lab curl -s -o /dev/null -w "%{http_code}\n" https://rosy-zany-sequoia-8888.box.sandboxer.com/api/status ``` ```text 401 401 ``` With the token, the first response sets a 30-day cookie and redirects. The root path then sends you on to Lab: ```text HTTP/2 302 location: / set-cookie: __sbx_auth=<cookie>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax HTTP/2 302 location: /lab? <title>JupyterLab ``` Open the full token URL in a browser and you get the Lab page. The browser keeps the cookie, so the file browser, the notebooks and the kernel websocket all work without extra steps. With JupyterLab's token off, anyone with the full URL can run code in the sandbox. Treat the URL like a password. Never combine this setup with `sbx host --public`. ## 4. Start a kernel and run cells through the API To check the kernel path without a browser, we drove the [Jupyter Server REST API](https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html) and the kernel websocket from Node 24. The script trades the hosted token for the cookie, loads `/lab` to get Jupyter's `_xsrf` cookie, starts a kernel, then sends each cell as an `execute_request` on the websocket ([message format](https://jupyter-client.readthedocs.io/en/latest/messaging.html#execute)): ```javascript // node jlab-run.mjs "" "" ["" …] // Set KERNEL_ID to reuse a running kernel instead of starting one. const start = new URL(process.argv[2]); const cells = process.argv.slice(3); const jar = new Map(); const keep = (res) => { for (const c of res.headers.getSetCookie()) { const [kv] = c.split(";"); const i = kv.indexOf("="); jar.set(kv.slice(0, i), kv.slice(i + 1)); } }; const cookie = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; "); const base = `${start.protocol}//${start.host}`; // 1. Trade the hosted-URL token for the __sbx_auth cookie. keep(await fetch(start, { redirect: "manual" })); // 2. Load the Lab page; Jupyter sets its _xsrf cookie. const lab = await fetch(`${base}/lab`, { headers: { Cookie: cookie() } }); keep(lab); console.log("GET /lab", lab.status); // 3. Start a kernel through the REST API (or reuse one). const t0 = performance.now(); let kernelId = process.env.KERNEL_ID; if (!kernelId) { const r = await fetch(`${base}/api/kernels`, { method: "POST", headers: { Cookie: cookie(), "X-XSRFToken": jar.get("_xsrf"), "Content-Type": "application/json" }, body: JSON.stringify({ name: "python3" }), }); const kernel = await r.json(); console.log("POST /api/kernels", r.status, kernel.id ?? kernel); kernelId = kernel.id; } // 4. Open the kernel websocket and run the cells in order. const ws = new WebSocket(`wss://${start.host}/api/kernels/${kernelId}/channels`, { headers: { Cookie: cookie() } }); const session = crypto.randomUUID(); let msgId; const run = () => { const code = cells.shift(); if (code === undefined) { console.log(`done in ${Math.round(performance.now() - t0)} ms`); process.exit(0); } msgId = crypto.randomUUID(); ws.send(JSON.stringify({ header: { msg_id: msgId, msg_type: "execute_request", username: "", session, version: "5.3", date: new Date().toISOString() }, parent_header: {}, metadata: {}, channel: "shell", content: { code, silent: false, store_history: true, user_expressions: {}, allow_stdin: false, stop_on_error: true }, })); }; ws.onopen = run; ws.onerror = (e) => { console.log("websocket error", e.message ?? e); process.exit(1); }; ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.parent_header?.msg_id !== msgId) return; const t = m.header.msg_type; if (t === "stream") process.stdout.write(m.content.text); if (t === "execute_result") console.log(m.content.data["text/plain"]); if (t === "error") console.log(m.content.ename, m.content.evalue); if (t === "execute_reply") { console.log(`[${m.content.execution_count}] ${m.content.status}`); run(); } }; ``` This run starts a kernel, installs a package from inside it with `%pip`, and uses it: ```bash node jlab-run.mjs "$URL" "%pip install -q humanize" \ "import humanize; humanize.naturalsize(3_500_000_000)" "import sys; sys.executable" ``` ```text GET /lab 200 POST /api/kernels 201 881945a3-7fe0-4232-896a-447045e2a2bf Note: you may need to restart the kernel to use updated packages. [1] ok '3.5 GB' [2] ok '/home/user/jlab/bin/python' [3] ok done in 2899 ms ``` The package went into `~/jlab`, the venv the kernel runs in. A single cell with no install (start the kernel, run it, get the reply) took 0.96 s end to end. What the proxy and Jupyter checked along the way: | Request | Result | | - | - | | Any path, no hosted token or cookie | `401` from the proxy | | `POST /api/kernels` with the cookie but no `X-XSRFToken` header | `403` from Jupyter | | `POST /api/kernels` with the cookie and `X-XSRFToken` | `201` | | `wss://…/api/kernels//channels` with the cookie | Connected, cells ran | The websocket needs the `__sbx_auth` cookie. Passing `?_token=` on the websocket URL gets a redirect, which websocket clients treat as a failed handshake ([details](/guides/give-a-sandbox-port-an-https-url)). Browsers already send the cookie. ## 5. Or keep JupyterLab's own token If you would rather let JupyterLab check logins, set its token and leave its auth on. We tested this on a public hosted port, so only Jupyter's token stood in the way: ```bash # /etc/systemd/system/jupyterlab.service.d/token.conf [Service] ExecStart= ExecStart=/home/user/jlab/bin/jupyter lab --no-browser --ip=0.0.0.0 --port=8888 Environment=JUPYTER_TOKEN=
``` ```text /lab, no token 302 -> /login?next=%2Flab /api/kernels, no token 403 /api/kernels, Authorization: token 200 kernel websocket with ?token=… connected, cell printed "hello over wss" ``` Both setups work through the proxy. With a private hosted port and Jupyter's token off (steps 2 and 3), you keep one secret, and the proxy rejects bad requests before they reach Jupyter. A public port with Jupyter's token puts the JupyterLab login page on the open internet. Switching a port between public and private also mints a new hosted token, so old private links stop working. ## 6. What survives stop and resume We saved a notebook through the contents API and set `x = 42` in a running kernel (PID 918). Then: ```bash sbx stop $id --wait sbx resume $id ``` The stop took 2.2 s and the resume 1.4 s, with `memoryRestored: true`. The browser cookie from before the stop still worked, and the same kernel answered: ```text old cookie /lab: 200 ['demo.ipynb'] 1 kernels (42, 918, '1.0 GB') ``` That's the variable, the same PID and the `humanize` import, all from before the stop. A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced it with `sbx resume $id --type default` (`memoryRestored: false`, `resumeReason: "resized"`). systemd started JupyterLab again, and `/api/status` answered `200` on our first poll after the resume returned. The notebook file and the installed package were still there. The kernel was gone: ```text ['demo.ipynb'] [] POST /api/kernels 201 f6bebce0-35f2-4b94-919d-bc437b4259c4 '1.0 GB' [1] ok NameError name 'x' is not defined [2] error ``` Save your notebooks and rerun them after a boot from disk. Packages you installed into the venv stay. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy `~/notebooks` across. See [snapshots](/docs/snapshots#resume) for the resume rules. ## 7. Clean up ```bash sbx delete $id --yes ``` ## FAQ ### Do I need JupyterLab's token if the hosted URL is private? No. In our test, every request we sent without the hosted token got `401` from the proxy, so it never reached JupyterLab. Turn Jupyter's token off only when the port is private, and keep the full URL secret. ### Why does %pip say "No module named pip"? uv makes venvs without pip unless you pass `--seed`. Create the venv with `uv venv --seed`, as in step 1, and `%pip install` installs into the venv the kernel runs in. ### Do my notebooks survive when the sandbox stops? Yes. Notebooks are files on the sandbox disk, and the disk is kept across stop and resume. With memory restored, running kernels and their variables come back too. After a boot from disk, the kernels are gone and you rerun your cells. ### Why do I get 403 when I call the Jupyter API with curl? With Jupyter's token off, Jupyter checks its `_xsrf` cookie on POST requests. Load `/lab` once to get the cookie, then send its value in an `X-XSRFToken` header, as the script in step 4 does. ### Can several people use the same JupyterLab? Anyone with the full private URL can open it. They share one Linux user and one set of kernels. For separate workspaces, give each person their own sandbox. ## Related - [Build a stateful code interpreter with a Jupyter kernel](/guides/build-a-stateful-code-interpreter-with-jupyter) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How to Run Metabase in a Cloud Sandbox Source: https://sandboxer.com/guides/run-metabase-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official metabase/metabase Docker image in a Sandboxer microVM with a volume for its application database, then host port 3000 on a private URL. It was healthy about 15 seconds after the JVM started, used 1.3 to 1.8 GB of RAM, and answered Sample Database queries in 5 to 20 ms. ## What we ran Everything below ran on production on October 27, 2026: Metabase v0.64.3.5 from the `metabase/metabase:latest` image (1.08 GB, OpenJDK 25), once on a `default` sandbox (4 vCPU, 8 GB) and once on a `small` one (2 vCPU, 4 GB). Timings are single runs; yours will differ. | Step | `default` | `small` | | - | - | - | | Pull the image and start, until `/api/health` says ok | 55 s | 37 s | | Metabase's own "Initialization COMPLETE" | 14.5 s | 15.9 s | | `docker restart` until healthy again | | 27 s | | Container memory after setup | 1.6 to 1.8 GB | 1.3 to 1.4 GB | | `SELECT COUNT(*) FROM ORDERS` on the Sample Database, warm | 5 to 20 ms in Metabase | 19 to 22 ms | | Same query from a laptop through the hosted URL | 230 to 280 ms round trip | | | `sbx stop --wait`, then `sbx resume` | 2.2 s stop, 1.4 s resume, `memoryRestored: true` | | Both sizes worked. On `small`, Metabase left about 2.2 GB of the VM's memory available. Pick `default` if you will connect larger databases or run heavy dashboards; `small` costs half as much ($0.018/hour against $0.036/hour). ## 1. Start Metabase with a volume Metabase's [Docker install docs](https://www.metabase.com/docs/latest/installation-and-operation/running-metabase-on-docker) give the image and the `MB_DB_FILE` setting. Docker is preinstalled in every sandbox. The pull takes a while, so raise `sbx exec`'s 30-second default timeout: ```bash sbx new --name metabase --type default --no-env id=sb_65jj424u1o # the id that sbx new printed sbx exec $id --timeout 600 "docker run -d --name metabase --restart unless-stopped \ -p 3000:3000 -v mbdata:/metabase-data -e MB_DB_FILE=/metabase-data/metabase.db \ metabase/metabase:latest" sbx exec $id --timeout 300 "until curl -sf localhost:3000/api/health >/dev/null; do sleep 1; done; \ curl -s localhost:3000/api/health; docker logs metabase 2>&1 | grep 'Initialization COMPLETE in'" ``` ```text {"status":"ok"} … INFO core.core :: Metabase Initialization COMPLETE in 14.5 s (JVM uptime: 37.1 s) ``` The volume is the part people skip. Metabase stores its own settings, users, saved questions and dashboards in an application database. Without `MB_DB_FILE` on a volume, that file lives in the container's writable layer, and a new container (an upgrade, or `docker rm` by mistake) starts from the setup screen again. We checked by running a second container without the volume next to the first: ```text port 3000: {"has-user-setup":true} # recreated container, same mbdata volume port 3001: {"has-user-setup":false} # new container, no volume ``` For anything beyond a trial, Metabase recommends Postgres or MySQL as the [application database](https://www.metabase.com/docs/latest/installation-and-operation/configuring-application-database) instead of the file. We used the file here. ## 2. Give it a private URL, before you finish setup ```bash sbx host $id 3000 --title Metabase ``` ```text https://broad-cobalt-brook-3000.box.sandboxer.com?_token= ``` Do this with the default private access, not `--public`, and finish setup right away. Until someone creates the first admin, Metabase returns its setup token from `GET /api/session/properties` with no login at all (the script in step 3 reads it that way). On a public URL, whoever got there first could create the admin account. On the private URL, a request without the token never reaches Metabase: ```bash curl -si https://broad-cobalt-brook-3000.box.sandboxer.com/ | head -1 curl -si -c jar "https://broad-cobalt-brook-3000.box.sandboxer.com?_token=" | grep -iE '^HTTP|^location' curl -s -b jar -o /dev/null -w '%{http_code} %{time_total}\n' https://broad-cobalt-brook-3000.box.sandboxer.com/api/health ``` ```text HTTP/2 401 HTTP/2 302 location: / 200 0.223655 ``` Open the full `?_token=` URL in your browser. The proxy sets a 30-day cookie and redirects to `/`, and Metabase's setup screen loads. How the token and cookie work is in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). ## 3. Finish setup and query the Sample Database from a script The browser wizard works. If an agent or a CI job sets Metabase up, use the API instead (see [Metabase's API docs](https://www.metabase.com/docs/latest/api)). This script runs on your laptop, goes through the private URL with the cookie jar from step 2, creates the admin, and runs a native query on the bundled Sample Database: ```bash #!/bin/bash set -euo pipefail B=https://broad-cobalt-brook-3000.box.sandboxer.com J=./jar # cookie jar from the ?_token= visit MB_PASS=$(openssl rand -base64 18) # admin password echo "$MB_PASS" > ./mb_pass && chmod 600 ./mb_pass TOKEN=$(curl -s -b $J $B/api/session/properties | jq -r '."setup-token"') SESSION=$(curl -s -b $J -H 'Content-Type: application/json' -X POST $B/api/setup -d "$(jq -n \ --arg t "$TOKEN" --arg p "$MB_PASS" \ '{token:$t, user:{first_name:"Ada", last_name:"Admin", email:"admin@example.com", password:$p, site_name:"Sandbox BI"}, prefs:{site_name:"Sandbox BI", site_locale:"en", allow_tracking:false}}')" | jq -r .id) H="X-Metabase-Session: $SESSION" curl -s -b $J -H "$H" $B/api/database | jq -c '.data[] | {id, name, engine, is_sample}' curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/dataset -d '{ "database": 1, "type": "native", "native": {"query": "SELECT p.CATEGORY, COUNT(*) AS ORDERS, ROUND(SUM(o.TOTAL), 2) AS REVENUE FROM ORDERS o JOIN PRODUCTS p ON o.PRODUCT_ID = p.ID GROUP BY p.CATEGORY ORDER BY REVENUE DESC"}}' \ | jq -c '{status, row_count, running_time, rows: .data.rows}' ``` ```text {"id":1,"name":"Sample Database","engine":"sqlite","is_sample":true} {"status":"completed","row_count":4,"running_time":233,"rows":[["Widget",5061,423667.5],["Gadget",4939,406621.25],["Gizmo",4784,383061.95],["Doohickey",3976,297270.99]]} ``` In this version the Sample Database is a SQLite file (`engine: sqlite`), added automatically at setup. The first query took 233 ms. Later ones, after logging in again with `POST /api/session`, ran in 5 to 20 ms inside Metabase and took 230 to 280 ms end to end from our laptop. Most of that is the network path to the sandbox. Keep the session id and password out of logs. The script above never prints them. ## 4. Connect a real database The Sample Database is for learning the UI. To chart your own data, run the database next to Metabase on a Docker network and add it by container name. We started Postgres 17 with a small test table: ```bash sbx exec $id --timeout 600 "docker network create bi && docker network connect bi metabase && \ docker run -d --name pg --network bi --restart unless-stopped -e POSTGRES_PASSWORD=pw postgres:17 && \ until docker exec pg pg_isready -q; do sleep 0.5; done && \ docker exec pg psql -U postgres -c \"CREATE TABLE signups AS SELECT d::date AS day, (random()*100)::int AS n \ FROM generate_series(now()-interval '29 days', now(), interval '1 day') d\"" ``` Then add it through the same API (`$SESSION` from a fresh `POST /api/session` login): ```bash DB=$(curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/database -d '{ "engine": "postgres", "name": "App DB", "details": {"host": "pg", "port": 5432, "dbname": "postgres", "user": "postgres", "password": "pw"}}' | jq .id) curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/dataset -d "{ \"database\": $DB, \"type\": \"native\", \"native\": {\"query\": \"SELECT count(*) AS days, sum(n) AS signups FROM signups\"}}" \ | jq -c '{status, rows: .data.rows}' ``` ```text {"status":"completed","rows":[[30,1355]]} ``` The host is `pg`, the container name, because both containers share the `bi` network. Postgres publishes no port, so nothing outside the VM can reach it. For a vector database or a database you seed from an agent, see [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) and [Test text-to-SQL queries against a real Postgres](/guides/test-text-to-sql-queries-against-a-real-postgres). ## 5. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker inspect -f 'restarts={{.RestartCount}}' metabase; curl -s localhost:3000/api/health" ``` ```text restarts=0 {"status":"ok"} ``` The stop took 2.2 seconds and the resume 1.4 seconds, with `memoryRestored: true`. The JVM was never restarted, so Metabase was usable at once: the old browser cookie still worked, and a login plus query returned the same 18,760 orders. A resume boots from disk instead when no machine with the same CPU type is free, or when you resume on a bigger size. Then the `--restart unless-stopped` policy starts Metabase again, which took 27 seconds in our `docker restart` test on `small`. Users, questions and dashboards survive because they are in the `mbdata` volume on the sandbox disk. See [snapshots](/docs/snapshots#resume) for the rules. A sandbox nobody uses pauses itself after `idleStopMinutes` (15 to 120 minutes by default, depending on your plan; our test sandbox had 30). Requests to its hosted URL count as activity, and a pause is a stop with memory, so Metabase comes back as it was. To keep a shared dashboard from pausing, see [idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use). ## 6. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Is a small sandbox enough for Metabase? For the Sample Database and light use, yes. On `small` (4 GB), the container used 1.3 to 1.4 GB after setup, queries ran in about 20 ms, and the VM still had 2.2 GB available. Use `default` for bigger data sources, many users, or heavy dashboards. ### Why do I land on the setup screen again after recreating the container? The application database was inside the old container. Start Metabase with `-v mbdata:/metabase-data -e MB_DB_FILE=/metabase-data/metabase.db` (or point it at Postgres) so users, questions and dashboards live outside the container. ### Can I make the dashboard public? `sbx host $id 3000 --public` opens the URL to anyone, and Metabase's own login still protects the data. Do it only after setup is finished, because before that anyone can claim the admin account. Switching back to private mints a new token, so old links stop working. ### Can Metabase in a sandbox query a database outside it? Yes, if that database accepts connections from the sandbox. Sandboxes have outbound internet (the image pull above used it). We tested only databases inside the same VM. What a sandbox can reach is in [What can reach a sandbox and what it can reach](/guides/what-can-reach-a-sandbox-and-what-it-can-reach). ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Hosting](/docs/hosting) --- # How Do I Run Grafana and Prometheus in a Cloud Sandbox? Source: https://sandboxer.com/guides/run-grafana-and-prometheus-in-a-sandbox. Updated 2026-10-27. **Short answer:** Put Prometheus, node_exporter and Grafana in one compose.yaml with a provisioned data source and dashboard, run docker compose up -d in a default Sandboxer sandbox, and host only Grafana's port 3000 with sbx host. Prometheus stays internal, the URL is private by default, and data and logins survive stop, resume and reboot. ## At a glance Everything below ran on one `default` sandbox (4 vCPU, 8 GB RAM) on October 27, 2026, from a laptop in the US to a sandbox in `eu-central`. Single runs, so treat timings as rough. | Check | What we saw | | - | - | | `docker compose up -d` (3 images pulled, 1.07 GB) | 10.6 s | | Grafana `/api/health` | `"database": "ok"`, version `12.2.0`, within 4 s of the container starting | | Memory used by the stack | Grafana 101 MiB, Prometheus 28 MiB, node_exporter 8 MiB | | Hosted URL without the token | `401`, also for `/api/health` and the Live WebSocket | | Grafana Live WebSocket with the cookie | Connected (`"connect":{…"ping":25,"pong":true}`) | | Dashboard query, inside the sandbox | Median 5.3 ms (20 runs) | | Same query through the hosted URL | Median 194 ms (20 runs, almost all of it the US to EU round trip) | | `sbx stop --wait` + `sbx resume` | `memoryRestored: true`, same PIDs, Grafana session still valid | | Reboot (cold boot) | All 3 containers back, metrics from before the reboot kept, session kept | ## 1. The stack Three services, two named volumes, and only Grafana publishes a port. Prometheus and node_exporter stay on the Compose network: Grafana reaches them by service name, and nothing outside the sandbox can. ```text mon/ ├── compose.yaml ├── prometheus.yml └── grafana/ ├── provisioning/datasources/prometheus.yaml ├── provisioning/dashboards/files.yaml └── dashboards/sandbox.json ``` ```yaml # mon/compose.yaml services: prometheus: image: prom/prometheus:v3.5.0 command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=7d volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prom-data:/prometheus restart: unless-stopped node-exporter: image: prom/node-exporter:v1.9.1 command: - --path.rootfs=/host pid: host volumes: - /:/host:ro,rslave restart: unless-stopped grafana: image: grafana/grafana:12.2.0 ports: - "3000:3000" environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set it} GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000} GF_USERS_ALLOW_SIGN_UP: "false" volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana depends_on: - prometheus restart: unless-stopped volumes: prom-data: grafana-data: ``` ```yaml # mon/prometheus.yml global: scrape_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: ["localhost:9090"] - job_name: node static_configs: - targets: ["node-exporter:9100"] ``` The images and flags follow each project's own Docker instructions: [Prometheus](https://prometheus.io/docs/prometheus/latest/installation/), [node_exporter](https://github.com/prometheus/node_exporter#docker) (`pid: host` and the read-only root mount let it report the sandbox VM rather than its own container) and [Grafana](https://grafana.com/docs/grafana/latest/setup-grafana/installation/docker/). ### Provision the data source and a dashboard Clicking a data source together in the UI works, but files make the stack reproducible: copy the folder into a fresh sandbox and the dashboards are there on first start. Grafana reads these at boot ([provisioning docs](https://grafana.com/docs/grafana/latest/administration/provisioning/)). ```yaml # mon/grafana/provisioning/datasources/prometheus.yaml apiVersion: 1 datasources: - name: Prometheus uid: prom type: prometheus access: proxy url: http://prometheus:9090 isDefault: true ``` ```yaml # mon/grafana/provisioning/dashboards/files.yaml apiVersion: 1 providers: - name: files folder: Sandbox type: file options: path: /var/lib/grafana/dashboards ``` `dashboards/sandbox.json` holds a dashboard with uid `sandbox-node` and four panels. The queries are the useful part: | Panel | PromQL | | - | - | | CPU busy (%) | `100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])))` | | Memory used (GiB) | `(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 2^30` | | Scrape targets up | `sum(up)` | | Root disk used (%) | `100 * (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})` | Each panel sets `"datasource": { "type": "prometheus", "uid": "prom" }`, matching the provisioned uid. ## 2. Start it Host the port first, so you know the URL to give Grafana as its `root_url`. A hosted port with nothing listening yet is fine. ```bash sbx new --name monitoring --type default id=sb_kspm9i99vr # the id that sbx new printed sbx scp -r ./mon $id:/home/user/ sbx host $id 3000 --title Grafana ``` ```text https://fresh-spare-glade-3000.box.sandboxer.com?_token= ``` Generate the admin password inside the sandbox so it never passes through your shell history, then start the stack: ```bash sbx exec $id --timeout 600 "cd mon && \ printf 'GRAFANA_ADMIN_PASSWORD=%s\nGRAFANA_ROOT_URL=https://fresh-spare-glade-3000.box.sandboxer.com/\n' \$(openssl rand -hex 16) > .env && \ chmod 600 .env && time docker compose up -d" ``` ```text Container mon-prometheus-1 Started Container mon-grafana-1 Starting Container mon-node-exporter-1 Started Container mon-grafana-1 Started real 0m10.605s ``` Read the password with `sbx exec $id "grep ^GRAFANA_ADMIN_PASSWORD= mon/.env"` when you sign in. Leave the token out of `GRAFANA_ROOT_URL`. Grafana uses it to build absolute links (share links, alert notifications), and the Sandboxer cookie, not the URL, carries access after the first visit. See [`root_url`](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#root_url). ```bash sbx exec $id "curl -s localhost:3000/api/health; cd mon && docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'" ``` ```text { "database": "ok", "version": "12.2.0", … } SERVICE STATUS PORTS grafana Up 4 seconds 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp node-exporter Up 4 seconds 9100/tcp prometheus Up 4 seconds 9090/tcp ``` ## 3. Two locks on one URL The hosted URL is private, and Grafana has its own login behind it. What the proxy and Grafana returned: | Request | Status | | - | - | | `/` or `/api/health`, no token | `401` from the proxy. Grafana never sees it | | The full URL with `?_token=` | `302` to `/`, sets the `__sbx_auth` cookie | | `/` with the cookie | `302` to `/login` (Grafana's own login) | | `POST /login` with the cookie | `{"message":"Logged in","redirectUrl":"/"}`, sets `grafana_session` | | `GET /api/user` with Basic auth and the cookie | `200`, `"login": "admin"` | | `GET /api/user` with Basic auth, no cookie | `401` from the proxy | | WebSocket `/api/live/ws`, no cookie | `401` | | WebSocket `/api/live/ws` with the cookie | Connected, Grafana Live answered | Two things follow. Grafana's `Authorization` header passes through the proxy untouched, so API scripts work, as long as they also send the `__sbx_auth` cookie. And Grafana Live, which pushes dashboard updates over a WebSocket, works through the hosted URL. Logged in, the provisioned pieces were there: ```bash curl -s -b jar.txt "$GRAFANA/api/search?type=dash-db" curl -s -b jar.txt "$GRAFANA/api/datasources/proxy/uid/prom/api/v1/targets" ``` ```text Sandbox node (folder: Sandbox, url /d/sandbox-node/sandbox-node) node http://node-exporter:9100/metrics up prometheus http://localhost:9090/metrics up ``` The second call goes through Grafana's data source proxy. You get the Prometheus API from outside without hosting port 9090, and only after a Grafana login. ### Which access mode to pick | Who needs the dashboards | Do this | | - | - | | Only you, or people you send the link to | Keep the default private URL. Two locks: token, then Grafana login | | A script or CI job calling the Grafana API | Private URL. Open the token URL once with a cookie jar, then send the cookie plus Grafana Basic auth or a service account token | | Prometheus queried from your laptop | Don't host 9090. Use the Grafana data source proxy as above | | Anyone on the internet | `sbx host $id 3000 --public`. Grafana's login is then the only gate, so keep `GF_USERS_ALLOW_SIGN_UP=false` and a strong admin password | ## 4. How fast queries are We timed the CPU panel's query over a 1 hour range through `POST /api/ds/query`, 20 times each: | Where the request came from | Min | Median | Max | | - | - | - | - | | Inside the sandbox (`localhost:3000`) | 4.2 ms | 5.3 ms | 16.2 ms | | A laptop in the US, through the hosted URL | 192 ms | 194 ms | 201 ms | Grafana and Prometheus answer in about 5 ms. Through the URL, the rest is network distance to `eu-central`: a plain `/api/health` took 198 ms from the same laptop. If dashboards feel slow from far away, distance is the cause, not the stack. ## 5. What a stop looks like in your graphs We noted the PIDs, stopped the sandbox at 09:49:52 UTC, left it stopped for about two minutes, and resumed at 09:52:03. ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker inspect -f '{{.Name}} pid={{.State.Pid}}' \$(docker ps -q)" ``` ```text ready memoryRestored=true # from the resume JSON /mon-grafana-1 pid=887 /mon-node-exporter-1 pid=826 /mon-prometheus-1 pid=749 ``` Same PIDs as before the stop, and the Grafana session cookie still worked. While the sandbox was stopped, the URL returned `404`. It answered `200` right after resume, with no new `sbx host` call. The interesting part is the data. Prometheus stopped scraping with everything else, so the raw samples have a hole: ```text raw samples 11 first 09:48:52 last 09:53:17 gap 09:49:52 -> 09:50:53 (61 s) gap 09:50:53 -> 09:52:17 (84 s) ``` One sample is stamped 09:50:53, while the sandbox was stopped. We saw this once, on October 27, 2026, and did not track down why. One possible reason: after a resume with memory, timers based on elapsed time don't count the stopped time, so Prometheus's scrape schedule may run off from the wall clock for a while. If exact timing matters, restart time-sensitive services after a resume or schedule by wall clock. Either way, the graph tells a different story than the samples: | Query, 15 s step, 09:49 to 09:53:30 | What it showed | | - | - | | `up{job="node"}` | `1` at every step, including the whole stop | | `count_over_time(up{job="node"}[1m])` | `4` while running, fell to `1`, no value at 09:52:00 and 09:52:15, then climbed back to `4` | `up` stays at 1 because a paused Prometheus writes no staleness markers. A range query then reuses the last sample for up to 5 minutes, the default lookback ([staleness](https://prometheus.io/docs/prometheus/latest/querying/basics/#staleness)). Any stop shorter than that draws as a flat line. To see stops on a dashboard, graph `count_over_time(up{job="node"}[1m])`. With a 15 s scrape interval, anything under 4 means missed scrapes. Stops longer than 5 minutes show as a plain gap in every panel. ## 6. Cold boot: what comes back A resume boots from disk instead of restoring memory when it lands on a different CPU type, or when you change size. To test that path, we created a dashboard through the API (it lives in Grafana's database, not in the provisioning files), then rebooted with `sudo systemctl reboot`: ```text 6.50 22.01 # /proc/uptime: a fresh boot NAMES STATUS mon-grafana-1 Up 5 seconds mon-node-exporter-1 Up 5 seconds mon-prometheus-1 Up 5 seconds ``` ```text prometheus-1 | msg="WAL replay completed" … wal_replay_duration=9.060953ms session after reboot: admin dashboard: Made through the API dashboard: Sandbox node raw samples 16 first 09:48:52 last 09:54:52 gap 09:53:32 -> 09:54:07 (35 s) ``` Docker is an enabled service, and `restart: unless-stopped` brought all three containers back. Prometheus replayed its write-ahead log from the `prom-data` volume, so samples from before the reboot are still queryable. The reboot cost a 35 s gap. Grafana kept the API-made dashboard and the login session, both in the `grafana-data` volume. Without those named volumes, a cold boot would keep the files on disk but start Grafana and Prometheus with empty data. ## FAQ ### Which size should I use? `default` (4 vCPU, 8 GB) ran this stack with the three containers using under 140 MiB together. Size for whatever you are monitoring next to it. Moving to a smaller size isn't supported yet, so start on the size you plan to keep. ### Where is the admin password if I lose it? In `mon/.env` inside the sandbox, readable only by `user`. Print it to your own terminal with `sbx exec $id "grep ^GRAFANA_ADMIN_PASSWORD= mon/.env"`. It survives stop, resume and reboot with the rest of the disk. `GF_SECURITY_ADMIN_PASSWORD` only sets the password on Grafana's first start, so changing `.env` later doesn't change it. ### Why not host Prometheus's port 9090 too? You can, but Prometheus has no login of its own. Behind a private URL the token is its only protection. Grafana's data source proxy already gives logged-in users the Prometheus API, so most setups need only port 3000. ### Will the sandbox stop on its own and leave holes in my metrics? Yes, after one hour by default and after a period of inactivity, and nothing is scraped while it is stopped. For continuous collection, run `sbx extend $id --hours 12` or `sbx idle $id 0`, and watch `count_over_time(up[1m])` for gaps. ### Do I need to re-host port 3000 after a stop? No. Hosted routes and tokens are kept across stop and resume, and the subdomain doesn't change. A fork gets a new subdomain and no routes, so host 3000 again there and update `GRAFANA_ROOT_URL`. ## Related - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How to Run Apache Airflow in a Cloud Sandbox Source: https://sandboxer.com/guides/run-apache-airflow-in-a-sandbox. Updated 2026-10-27. **Short answer:** Install Airflow with pip and its constraints file in a venv, run airflow standalone as a systemd service with the venv on PATH, and host port 8080 on a private URL. In our run, Airflow 3.3.2 installed in 6 seconds, was healthy 9 seconds after a cold boot, and a REST-triggered three-task DAG finished in 5 to 8 seconds. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB, Ubuntu 24.04, Python 3.12.3). Airflow 3.3.2, installed the way the [Airflow quick start](https://airflow.apache.org/docs/apache-airflow/stable/start.html) shows. Timings are single runs. We then repeated steps 1 to 6 on a `small` sandbox (see [Which size](#which-size)). | Step | Result | | - | - | | `uv pip install` with the constraints file | 6.3 s, 213 MB venv | | `airflow db migrate` (SQLite) | 4.1 s | | `airflow dags test` of a three-task DAG, no scheduler | 9.8 s wall, run took 4.9 s | | `airflow standalone` under systemd, restart to healthy | about 8.5 s | | Memory of the whole Airflow service | 957 MB | | DAG run triggered over the REST API from outside | `success` in 5.3 to 8.3 s | | `GET /api/v2/monitor/health` through the hosted URL | 0.20 s median over 10 calls | | `sbx stop --wait`, then `sbx resume` | Memory restored, same PID, still healthy | | Resume on a bigger size (boots from disk) | systemd started Airflow, healthy at 9 s uptime, run history kept | `airflow standalone` runs the API server and UI, the scheduler, the DAG processor and the triggerer in one process tree, with SQLite and the `LocalExecutor`. Airflow's docs present it as a way to try Airflow, not as a production setup. That fits a sandbox: a private Airflow per branch, per agent or per experiment, which you delete when you're done. ## 1. Install Airflow with its constraints file Airflow pins its dependency tree in a constraints file per version and Python minor version. Installing without it can pull versions Airflow wasn't tested with. `uv` is preinstalled in every sandbox; the install took 6.3 s in our run. Save as `install.sh`: ```bash set -e AIRFLOW_VERSION=3.3.2 PYTHON_VERSION="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" CONSTRAINT_URL="https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt" uv venv ~/airflow-venv --python 3.12 . ~/airflow-venv/bin/activate uv pip install "apache-airflow==${AIRFLOW_VERSION}" --constraint "${CONSTRAINT_URL}" airflow version ``` ```bash sbx new --name airflow --type default id=sb_g8mptg3wbr # the id that sbx new printed sbx scp ./install.sh $id:/home/user/ sbx exec $id --timeout 600 "bash install.sh 2>&1 | tail -3" ``` ```text + wrapt==2.4.1 + zipp==4.1.0 3.3.2 ``` `sbx exec` stops a command after 30 seconds by default. This install took 6.3 seconds, but pass `--timeout 600` anyway: adding providers can take longer. ## 2. Write a DAG and test it without a scheduler This DAG uses the Airflow 3 TaskFlow API (`airflow.sdk`). It makes a real HTTP call from inside the sandbox, passes the result between tasks through XCom, and writes a file. Save as `hello_pipeline.py`: ```python from datetime import datetime import json, platform, statistics, urllib.request from airflow.sdk import dag, task @dag(schedule="@hourly", start_date=datetime(2026, 10, 1), catchup=False, tags=["sandbox"]) def hello_pipeline(): @task def extract() -> list[float]: # Real network call from inside the sandbox: wheel sizes of every apache-airflow release with urllib.request.urlopen("https://pypi.org/pypi/apache-airflow/json", timeout=20) as r: releases = json.load(r)["releases"] sizes = [f["size"] / 1e6 for files in releases.values() for f in files if f["packagetype"] == "bdist_wheel"] return sizes @task def transform(sizes: list[float]) -> dict: return { "wheels": len(sizes), "median_mb": round(statistics.median(sizes), 2), "max_mb": round(max(sizes), 2), "host": platform.node(), } @task def load(summary: dict) -> None: with open("/home/user/airflow/report.json", "w") as f: json.dump(summary, f) print("report:", summary) load(transform(extract())) hello_pipeline() ``` `airflow dags test` runs every task once in a single process, with no scheduler or webserver. It's a quick check that a DAG parses and works (9.8 s in our run), and the one to give a coding agent that is editing DAGs: ```bash sbx exec $id "mkdir -p ~/airflow/dags" sbx scp ./hello_pipeline.py $id:/home/user/airflow/dags/ sbx exec $id --timeout 300 "export AIRFLOW_HOME=~/airflow AIRFLOW__CORE__LOAD_EXAMPLES=False . ~/airflow-venv/bin/activate airflow db migrate > /tmp/migrate.log 2>&1 airflow dags test hello_pipeline 2>&1 | grep -E 'report:|DagRun Finished'" ``` ```text report: {'wheels': 289, 'median_mb': 5.25, 'max_mb': 13.68, 'host': 'cloudy-fancy-sapphire'} … DagRun Finished: dag_id=hello_pipeline, … run_duration=4.895654, state=success, run_type=manual, … ``` Its exit code is non-zero when a task fails, so an agent can loop on it: edit the DAG, run `dags test`, read the log, repeat. ## 3. Run Airflow standalone as a systemd service A systemd service starts again when the sandbox boots from disk, which a process started with `sbx exec --detach` doesn't. Save as `airflow.service`: ```ini [Unit] Description=Apache Airflow (standalone) After=network-online.target Wants=network-online.target [Service] User=user WorkingDirectory=/home/user/airflow Environment=AIRFLOW_HOME=/home/user/airflow Environment=AIRFLOW__CORE__LOAD_EXAMPLES=False Environment=PATH=/home/user/airflow-venv/bin:/usr/local/bin:/usr/bin:/bin ExecStart=/home/user/airflow-venv/bin/airflow standalone StandardOutput=append:/home/user/airflow/standalone.log StandardError=inherit Restart=on-failure RestartSec=5 KillMode=mixed TimeoutStopSec=30 [Install] WantedBy=multi-user.target ``` ```bash sbx scp ./airflow.service $id:/home/user/ sbx exec $id "sudo mv airflow.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now airflow" sbx exec $id --timeout 170 "until curl -sf -o /dev/null http://localhost:8080/api/v2/monitor/health; do sleep 1; done curl -s http://localhost:8080/api/v2/monitor/health" ``` ```json {"metadatabase":{"status":"healthy"},"scheduler":{"status":"healthy","latest_scheduler_heartbeat":"2026-10-27T09:41:59.811942+00:00"},"triggerer":{"status":"healthy",…},"dag_processor":{"status":"healthy",…}} ``` **The `PATH` line is required.** `airflow standalone` starts its components by running `airflow` from `PATH`. Our first unit left the line out. systemd reported the service `active (running)`, but nothing listened on 8080, and running it by hand showed why: ```text Exception in thread dag-processor: … FileNotFoundError: [Errno 2] No such file or directory: 'airflow' ``` The parent process stays up when its children fail to start, so `Restart=on-failure` never fires. Check the health endpoint, not `systemctl is-active`. Standalone creates an `admin` user and writes its generated password to `~/airflow/simple_auth_manager_passwords.json.generated`. Read it inside the sandbox when you need it. Don't paste it into logs or chat. ## 4. Open the UI on a private URL Airflow binds to `0.0.0.0:8080`, so `sbx host` reaches it without `--localhost`. Keep it private, which is the default: ```bash sbx host $id 8080 --title airflow ``` ```json {"ok":true,"type":"port.hosted","sandboxId":"sb_g8mptg3wbr","port":8080,"url":"https://cloudy-fancy-sapphire-8080.box.sandboxer.com?_token=","access":"private",…} ``` ```text no token: 401 with ?_token=: 302 (sets the __sbx_auth cookie) UI with the cookie: 200 ``` Open the full URL in a browser and sign in as `admin`. That makes two locks: the sandbox token keeps everyone else away from the page, and Airflow's own login sits behind it. Treat the URL and the cookie like passwords. ## 5. Trigger a DAG run over the REST API Airflow 3's REST API lives under `/api/v2` and takes a JWT from `POST /auth/token`. Through a private hosted URL, a request needs both: the sandbox cookie to pass the proxy, and the bearer token for Airflow. This script ran on our laptop. It reads the admin password with `sbx exec` straight into a variable and never prints a secret: ```bash URL="https://cloudy-fancy-sapphire-8080.box.sandboxer.com?_token=" # from sbx host BASE=${URL%%\?*} curl -s -o /dev/null -c jar "$URL" # 302: trade the token for the cookie PW=$(sbx exec $id "python3 -c 'import json;print(json.load(open(\"/home/user/airflow/simple_auth_manager_passwords.json.generated\"))[\"admin\"])'") JWT=$(curl -s -b jar -X POST $BASE/auth/token -H 'Content-Type: application/json' \ -d "{\"username\":\"admin\",\"password\":\"$PW\"}" | jq -r .access_token); unset PW api() { curl -s -b jar -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' "$@"; } api -X PATCH $BASE/api/v2/dags/hello_pipeline -d '{"is_paused":false}' | jq -c '{dag_id,is_paused}' RID=$(api -X POST $BASE/api/v2/dags/hello_pipeline/dagRuns -d '{"logical_date":null}' | jq -r .dag_run_id) E=$(jq -rn --arg r "$RID" '$r|@uri') until st=$(api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E" | jq -r .state); [ "$st" = success ] || [ "$st" = failed ]; do sleep 1; done echo "state=$st" api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E/taskInstances" | jq -c '.task_instances[] | {task_id,state,duration}' api "$BASE/api/v2/dags/hello_pipeline/dagRuns/$E/taskInstances/transform/xcomEntries/return_value" | jq -c .value ``` ```text {"dag_id":"hello_pipeline","is_paused":false} state=success {"task_id":"extract","state":"success","duration":3.613} {"task_id":"transform","state":"success","duration":0.138} {"task_id":"load","state":"success","duration":0.074} {"wheels":289,"median_mb":5.25,"max_mb":13.68,"host":"cloudy-fancy-sapphire"} ``` From the trigger call to `success` took 8.1 seconds, most of it the `extract` task's download from PyPI. Two later runs took 5.3 and 8.3 seconds. New DAGs start paused. `"logical_date": null` is how Airflow 3 asks for a manual run with no data interval. Unpausing an `@hourly` DAG with `catchup=False` also queued one `scheduled` run for the latest interval (09:00 UTC), as the run list showed: ```json {"run_type":"manual","state":"success","run_after":"2026-10-27T09:42:31.654995Z"} {"run_type":"scheduled","state":"success","run_after":"2026-10-27T09:00:00Z"} ``` An agent that already drives the sandbox through `sbx exec` can skip the REST API and the password. The Airflow CLI inside the sandbox talks to the same database: ```bash sbx exec $id "export AIRFLOW_HOME=~/airflow PATH=~/airflow-venv/bin:\$PATH; airflow dags trigger hello_pipeline -o plain 2>/dev/null" ``` ```text conf dag_id dag_run_id … {} hello_pipeline manual__2026-10-27T09:43:50.422068+00:00 … ``` ## 6. Keep the scheduler running for schedules A schedule fires only while the scheduler runs. A stopped or paused sandbox runs nothing, and a run that was due while it slept is handled by `catchup` when it wakes, like any Airflow restart. Two Sandboxer timers can stop it: - **Auto-stop.** Every sandbox has a deadline, one hour by default ([auto-stop](/docs/long-running#auto-stop)). - **Idle auto-pause.** A sandbox with no activity pauses itself ([idle auto-pause](/docs/long-running#idle-auto-pause-and-wake-on-use)). A request to the private URL that carries its token wakes it, but a schedule inside the VM can't. For a sandbox whose job is to run schedules, turn both off, and stop it yourself when you're done: ```bash sbx idle $id 0 sbx extend $id --no-auto-stop ``` Then check both settings: ```bash sbx info $id --json | jq -c '.sandbox | {idleStopMinutes, archiveAfter, ttlSeconds}' ``` ```json {"idleStopMinutes":0,"archiveAfter":null,"ttlSeconds":null} ``` A sandbox with no auto-stop bills for every hour it runs, so this is the one case where you have to remember the `sbx stop` or `sbx delete` yourself. ## 7. What survives a stop and resume ```bash sbx stop $id --wait # 2.2 s sbx resume $id # 1.4 s, "memoryRestored": true sbx exec $id "systemctl show airflow -p MainPID; curl -s http://localhost:8080/api/v2/monitor/health | jq -c '{scheduler: .scheduler.status, dag_processor: .dag_processor.status}'" ``` ```text MainPID=1356 {"scheduler":"healthy","dag_processor":"healthy"} ``` The PID was the same as before the stop: memory was restored, so nothing restarted. The private URL and its cookie kept working with no new `sbx host` call, and the next REST-triggered run succeeded in 5.3 seconds. A resume boots from disk instead when you change the size or no machine with the same CPU type is free. We forced that with `sbx resume $id --type large` (`memoryRestored: false`, `resumeReason: "resized"`). systemd started Airflow on boot: ```text 2.16 # uptime when our first command ran active 9.23 # uptime when /health answered {"scheduler":"healthy","triggerer":"healthy","dag_processor":"healthy"} ``` The SQLite database (776 KB after five runs), logs, DAG files and `report.json` all live in `~/airflow` on the sandbox disk. Every earlier run was still in the run list, and a new run succeeded in 8.3 seconds. Moving to a smaller size isn't supported yet. To shrink, create a new sandbox and copy `~/airflow` across. See [snapshots](/docs/snapshots#resume) for the rules. ## Which size We ran steps 1 to 6 a second time on a `small` sandbox (2 vCPU, 4 GB), which bills at half the `default` rate. Everything worked: | | `default` (4 vCPU, 8 GB) | `small` (2 vCPU, 4 GB) | | - | - | - | | Install | 6.3 s | 6.3 s | | `airflow dags test`, including start-up | 9.8 s | 14.6 s | | Restart or start to healthy | about 8.5 s | 12.4 s (first start) | | Airflow service memory, idle after runs | 957 MB | 1.18 GB | | REST-triggered run to `success` | 5.3 to 8.3 s | 8.7 s | On `small`, the whole VM used 1.5 GB of its 3.9 GB after two runs. That leaves room for light tasks. Use `default` when your tasks load real data into memory: under the `LocalExecutor`, each running task is its own process in the same VM. ## Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Why does systemd say Airflow is running but port 8080 doesn't answer? `airflow standalone` starts its components as `airflow` subprocesses found on `PATH`. systemd's default `PATH` doesn't include your venv, so they fail with `FileNotFoundError: 'airflow'` while the parent keeps running. Add `Environment=PATH=/home/user/airflow-venv/bin:…` to the unit. ### Can I use this setup in production? Airflow's docs present standalone with SQLite as a quick start, not a production deployment. For a long-lived scheduler, follow Airflow's [production deployment guide](https://airflow.apache.org/docs/apache-airflow/stable/administration-and-deployment/production-deployment.html), which starts with a Postgres or MySQL metadata database. We tested only standalone with SQLite. ### How do I test a DAG without starting the scheduler? Run `airflow dags test `. It runs every task once in one process and exits non-zero on failure. Our three-task DAG took 9.8 seconds, including Airflow's start-up. ### Do scheduled runs happen while the sandbox is stopped? No. Nothing runs in a stopped or paused sandbox. Turn off idle auto-pause and auto-stop on a sandbox whose job is to run schedules, and set `catchup` on each DAG to decide what happens to intervals that were missed. ### Do I lose my DAG runs when the sandbox stops? No. With memory restored, Airflow keeps running as if nothing happened. On a boot from disk, systemd starts it again and the SQLite database in `~/airflow` still holds every run. ## Related - [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use) - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) --- # How to Run the Temporal Dev Server in a Cloud Sandbox Source: https://sandboxer.com/guides/run-temporal-dev-server-in-a-sandbox. Updated 2026-10-27. **Short answer:** Install the Temporal CLI, run temporal server start-dev with --db-filename under systemd, and host the Web UI on port 8233 with sbx host --localhost. On a small sandbox the server was ready in 1.5 s and a one-activity workflow took about 100 ms. History survived stop, resume and a cold boot. ## At a glance Everything below ran on production on October 27, 2026: Temporal CLI 1.9.1 (Server 1.32.0, UI 2.54.1) and the Python SDK `temporalio` 1.34.0, first on a `small` sandbox (2 vCPU, 4 GB), then resized to `default` (4 vCPU, 8 GB). Single runs unless noted; your numbers will differ. | Step | Result | | - | - | | Install the Temporal CLI | 1.3 s | | Server start to `SERVING` (3 restarts) | 1.43, 1.53, 1.49 s | | One workflow with one activity, inside the sandbox (50 runs) | median 98 ms, min 47 ms | | Same workflow from a laptop through `sbx forward` (20 runs) | median 254 ms | | Web UI on a private hosted URL | 401 without the token, 302 then 200 with it | | Memory after start | server 185 MB RSS, Python worker 60 MB | | `sbx stop --wait` then `sbx resume` (memory restored) | Server and worker kept running; history intact | | Resume on a bigger size (boots from disk) | systemd restarted both; all 54 workflows still there | | Restart without `--db-filename` | 1 workflow before, 0 after | | Timer that came due while stopped | Fired about 96 s after a memory-restored resume; within 1 to 2 s after a server restart or cold boot | The last row is the one to read before you rely on timers. It is explained in [Timers that come due while the sandbox is stopped](#timers-that-come-due-while-the-sandbox-is-stopped). ## 1. Install the CLI and run the server under systemd Temporal's dev server is a single binary inside the Temporal CLI ([install the CLI](https://docs.temporal.io/cli/setup-cli), [`server start-dev` reference](https://docs.temporal.io/cli/server#start-dev)). Use its own install script: ```bash sbx new --name temporal --type small --no-env id=sb_0fcf3wsqh3 # the id that sbx new printed sbx exec $id "curl -sSf https://temporal.download/cli.sh | sh && ~/.temporalio/bin/temporal --version" ``` ```text temporal: Temporal CLI installed at /home/user/.temporalio/bin/temporal temporal version 1.9.1 (Server 1.32.0, UI 2.54.1) ``` Run it as a systemd unit, not a detached shell command, so it comes back when the sandbox boots from disk. Save this as `temporal.service`: ```ini [Unit] Description=Temporal dev server After=network-online.target [Service] User=user ExecStart=/home/user/.temporalio/bin/temporal server start-dev --ip 127.0.0.1 --db-filename /home/user/temporal.db --ui-disable-news-fetch Restart=always [Install] WantedBy=multi-user.target ``` ```bash sbx scp ./temporal.service $id:/home/user/ sbx exec $id "sudo mv temporal.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now temporal" sbx exec $id "until ~/.temporalio/bin/temporal operator cluster health 2>/dev/null | grep -q SERVING; do sleep 0.2; done; ss -ltn | grep -E '7233|8233'" ``` ```text LISTEN 0 4096 127.0.0.1:7233 0.0.0.0:* LISTEN 0 4096 127.0.0.1:8233 0.0.0.0:* ``` Three choices in that unit: - `--db-filename`. Without it, the dev server keeps everything in memory. We checked: a second server without the flag had 1 workflow before a restart and 0 after. With the flag, history lives in a SQLite file on the sandbox disk (1.7 MB after 56 workflows). It didn't slow anything down: 50 workflows took a median 98 ms with the file and 100 ms without it. - `--ip 127.0.0.1`. The gRPC port (7233) and the UI (8233) listen inside the VM only. The Web UI has no login in dev mode (`"Auth":{"Enabled":false}` in its settings), so you reach it through a private hosted URL (step 3), and the gRPC port through a tunnel (step 4). - `--ui-disable-news-fetch`. The UI stops calling out for its news feed. Optional. ## 2. Run a workflow Save the workflow, the worker and a starter. `ReminderWorkflow` is a durable timer; you will use it in step 5. ```python # workflows.py from datetime import timedelta from temporalio import activity, workflow @activity.defn async def greet(name: str) -> str: return f"Hello, {name}!" @workflow.defn class HelloWorkflow: @workflow.run async def run(self, name: str) -> str: return await workflow.execute_activity( greet, name, start_to_close_timeout=timedelta(seconds=10) ) @workflow.defn class ReminderWorkflow: @workflow.run async def run(self, minutes: int) -> str: started = workflow.now() await workflow.sleep(timedelta(minutes=minutes)) waited = (workflow.now() - started).total_seconds() return f"woke after {waited:.0f} s of workflow time" ``` ```python # worker.py import asyncio from temporalio.client import Client from temporalio.worker import Worker from workflows import HelloWorkflow, ReminderWorkflow, greet async def main(): client = await Client.connect("127.0.0.1:7233") worker = Worker(client, task_queue="demo", workflows=[HelloWorkflow, ReminderWorkflow], activities=[greet]) print("worker started on task queue 'demo'", flush=True) await worker.run() asyncio.run(main()) ``` ```python # starter.py import asyncio, os, statistics, sys, time, uuid from temporalio.client import Client from workflows import HelloWorkflow async def main(): client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "127.0.0.1:7233")) n = int(sys.argv[1]) if len(sys.argv) > 1 else 1 times = [] for i in range(n): t0 = time.perf_counter() result = await client.execute_workflow( HelloWorkflow.run, "sandbox", id=f"hello-{uuid.uuid4().hex[:8]}", task_queue="demo") times.append((time.perf_counter() - t0) * 1000) print(result) print(f"{n} workflows, median {statistics.median(times):.1f} ms, " f"min {min(times):.1f} ms, max {max(times):.1f} ms") asyncio.run(main()) ``` Install the SDK ([Temporal Python SDK](https://docs.temporal.io/develop/python)) in a virtual environment and run the worker as a second unit. `Requires=temporal.service` restarts the worker whenever you restart the server: ```ini # temporal-worker.service [Unit] Description=Temporal worker (task queue demo) After=temporal.service Requires=temporal.service [Service] User=user WorkingDirectory=/home/user/app ExecStart=/home/user/app/.venv/bin/python worker.py Restart=always RestartSec=2 [Install] WantedBy=multi-user.target ``` ```bash sbx exec $id "mkdir -p app" for f in workflows.py worker.py starter.py temporal-worker.service; do sbx scp ./$f $id:/home/user/app/; done sbx exec $id --timeout 600 "cd app && python3 -m venv .venv && .venv/bin/pip install -q temporalio" sbx exec $id "sudo mv app/temporal-worker.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now temporal-worker" sbx exec $id "cd app && .venv/bin/python starter.py 50" ``` ```text Hello, sandbox! 50 workflows, median 98.0 ms, min 47.0 ms, max 169.9 ms ``` That time covers start, a workflow task, the activity, a second workflow task and the result. The `pip install` took 2.5 s; `sbx exec` stops a command after 30 s by default, so keep `--timeout 600` on installs. The CLI talks to the same server: ```bash sbx exec $id "export PATH=\$PATH:~/.temporalio/bin temporal workflow start --type ReminderWorkflow --task-queue demo --workflow-id reminder-1 --input 3 temporal workflow list --limit 3" ``` ```text Status WorkflowId Type StartTime Running reminder-1 ReminderWorkflow now Completed hello-6a249bde HelloWorkflow 8 seconds ago Completed hello-846bcce8 HelloWorkflow 8 seconds ago ``` ## 3. Open the Web UI on a private URL The UI listens on `127.0.0.1:8233`, so host it with `--localhost` ([hosting docs](/docs/hosting#apps-that-listen-on-localhost)): ```bash sbx host $id 8233 --localhost --title "Temporal UI" ``` ```text {"ok":true,"type":"port.hosted","sandboxId":"sb_0fcf3wsqh3","port":8233,"url":"https://direct-humble-cabin-8233.box.sandboxer.com?_token=","access":"private","isProtected":true,"localhost":true} ``` Open that URL in a browser. We checked it with curl, using a cookie jar for the session cookie that the token sets: ```bash B=https://direct-humble-cabin-8233.box.sandboxer.com curl -s -o /dev/null -w "no token: %{http_code}\n" $B/ curl -s -o /dev/null -w "with token: %{http_code}\n" -c jar.txt "$B/?_token=" curl -s -b jar.txt "$B/api/v1/namespaces/default/workflows?query=WorkflowType%3D%22ReminderWorkflow%22" ``` ```text no token: 401 with token: 302 { "executions": [ { "execution": { "workflowId": "reminder-1", … "status": "WORKFLOW_EXECUTION_STATUS_RUNNING", ``` The UI's own `/api/v1` passes through the same URL, which is how the browser UI lists, describes and can cancel or terminate workflows. Anyone with the full URL can do all of that, so keep it private. `--public` would hand that control to anyone who finds the hostname. ## 4. Connect from your laptop A worker or client on your laptop needs the gRPC port, not the UI. Forward it with [`sbx forward`](/docs/ssh#forward-ports) and point the client at the local port: ```bash sbx forward $id --remote 7233 --local 17233 & TEMPORAL_ADDRESS=127.0.0.1:17233 python starter.py 20 ``` ```text Hello, sandbox! 20 workflows, median 254.1 ms, min 253.1 ms, max 262.2 ms ``` The extra 155 ms is the network path from our laptop to the sandbox, which depends on where you are. For the worker, keep it next to the server in the sandbox, as above. ## 5. What survives stop and resume We ran three cases, each with a `ReminderWorkflow` sleeping when the sandbox stopped. **Memory restored.** `sbx stop $id --wait` (2.2 s) and `sbx resume $id` (1.5 s, `memoryRestored: true`). systemd's `ActiveEnterTimestamp` for the server was unchanged: the processes were never restarted, the history was all there, and the hosted URL and its cookie worked again. While the sandbox was stopped, the URL answered `404`. **Boot from disk.** `sbx resume $id --type default` (`memoryRestored: false`, `resumeReason: "resized"`). Both units started at boot, the server read `temporal.db`, and `temporal workflow count` still said `Total: 54`. This is the case `--db-filename` and systemd are for. It also happens when no machine with the same CPU type is free. Moving to a smaller size isn't supported yet; to shrink, create a new sandbox and copy `temporal.db`. **Without `--db-filename`.** A restart alone emptied the server. A boot from disk would do the same. ### Timers that come due while the sandbox is stopped No timer was lost in any run. But in our runs on October 27, 2026, a timer that came due while the sandbox was stopped didn't always fire at once. This is what we observed, not documented behavior: | Timer | Stopped for | How the sandbox came back | Fired | | - | - | - | - | | 3 min | 2 min 49 s | Memory restored | 96 s after resume | | 3 min | 2 min 58 s | Memory restored | 96 s after resume | | 1 min | 1 min 33 s | Memory restored | within 2 s of resume | | 1 min | 1 min 14 s | Boot from disk | within 1 s of the server starting | | 3 min | 2 min 49 s | Memory restored, then `sudo systemctl restart temporal` | 2 s after the restart | The general rule: after a resume with memory, timers based on elapsed time don't count the stopped time; restart time-sensitive services or schedule by wall clock. In our session, `uptime -s` moved 108 s later across a 169 s stop, so elapsed time inside the VM counted only about 61 s of it. The server's in-process wake-up for the next timer runs on elapsed time, so it came late. On a fresh start, the server reads its due timers from the database and fires the overdue ones at once. The workflow result shows the effect: `workflow.now()` is wall-clock time, so a 3-minute reminder reported `woke after 296 s of workflow time`. If timers must fire promptly after a resume, restart the server as part of your resume step. It takes about 1.5 s, and the worker restarts with it: ```bash sbx resume $id sbx exec $id "sudo systemctl restart temporal" ``` ## 6. Clean up ```bash sbx delete $id --yes ``` A `small` sandbox bills at half the `default` rate of $0.036/hour while it runs, and nothing while it's stopped. ## FAQ ### Is the dev server fit for production? No. Temporal documents `start-dev` as a local development server with a SQLite store. Use it for development, tests, demos and agent experiments. For production, use Temporal Cloud or a self-hosted cluster as described in [Temporal's docs](https://docs.temporal.io/). ### Which size do I need? `small` (2 vCPU, 4 GB) was enough here. After about 120 workflows, the server used 185 MB of memory and the Python worker 60 MB. Size up for your own activities, not for the server. ### Why does the UI return 401? The hosted URL is private by default. Open the full URL with `?_token=` once; it sets a 30-day cookie and the UI works after that. A request without the token never reaches Temporal. ### Do I lose my workflow history when the sandbox auto-stops? Not with `--db-filename`. Auto-stop is a normal stop: a memory-restored resume keeps the running server, and a boot from disk reloads the SQLite file. Without the flag, history survives only a memory-restored resume. ### Can my laptop's worker poll the sandbox server? Yes, through `sbx forward` to port 7233, as in step 4. The hosted URL is HTTPS for the UI; we didn't test gRPC through it. ## Related - [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) - [Give a port in a sandbox an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Snapshots and resume](/docs/snapshots) --- # How to Run a Selenium Grid in a Cloud Sandbox Source: https://sandboxer.com/guides/run-selenium-grid-in-a-sandbox. Updated 2026-10-27. **Short answer:** Start the official Selenium hub, Chrome and Firefox node images with Docker Compose in a default Sandboxer sandbox, host port 4444 with sbx host, and pass the URL's token to webdriver.Remote as a cookie header. Our remote tests passed on both browsers; each WebDriver command took about 136 ms from outside and 7 ms inside. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB, $0.036/hour) created with `--no-env`. The Grid images are `selenium/hub`, `selenium/node-chrome` and `selenium/node-firefox` at tag `4.51.0-20261010`, from the [docker-selenium project](https://github.com/SeleniumHQ/docker-selenium). The test client is Python Selenium 4.51.0 on our own machine. Timings are single runs or short series, so treat them as rough. | Step | Result | | - | - | | `docker compose up -d` (pulls 3 images) | 25.7 s | | Grid status | Ready, Chrome 155.0 node (2 slots) and Firefox 158.0 node (1 slot) | | Hosted Grid URL without the token | `401` | | Remote test from outside, Chrome | New session 0.54 to 0.63 s, test 1.15 to 1.41 s (10 runs) | | Remote test from outside, Firefox | New session 1.85 to 2.18 s, test 1.30 to 1.53 s (3 runs) | | One WebDriver command, client outside | 135.9 ms median | | One WebDriver command, client inside the sandbox | 6.8 ms median | | 4 Chrome tests at once, 2 slots | All passed; the extra sessions waited in the Grid's queue, 7.9 s wall time | | Memory used with the Grid idle | About 1.5 GB of 8 GB | | `sbx stop --wait` then `sbx resume` | Memory restored, same containers, tests passed on the same URL | | Boot from disk | Containers back within 5 s; the first request came before the hub was listening, a retry worked | ## 1. Start the Grid with Docker Compose Docker and Compose are already installed in every sandbox. This file follows the hub-and-nodes layout from the [docker-selenium README](https://github.com/SeleniumHQ/docker-selenium#docker-compose), with three changes: pinned image tags, `restart: unless-stopped`, and two Chrome sessions per node. ```yaml # compose.yaml services: selenium-hub: image: selenium/hub:4.51.0-20261010 restart: unless-stopped ports: - "4444:4444" chrome: image: selenium/node-chrome:4.51.0-20261010 restart: unless-stopped shm_size: 2gb depends_on: [selenium-hub] environment: - SE_EVENT_BUS_HOST=selenium-hub - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 - SE_NODE_MAX_SESSIONS=2 - SE_NODE_OVERRIDE_MAX_SESSIONS=true ports: - "7900:7900" firefox: image: selenium/node-firefox:4.51.0-20261010 restart: unless-stopped shm_size: 2gb depends_on: [selenium-hub] environment: - SE_EVENT_BUS_HOST=selenium-hub - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 ``` ```bash sbx new --name selenium-grid --type default --no-env id=sb_hwfyk2qg7a # the id that sbx new printed sbx exec $id "mkdir -p grid" sbx scp compose.yaml $id:/home/user/grid/compose.yaml sbx exec $id --timeout 600 "cd grid && docker compose up -d" ``` ```text Container grid-selenium-hub-1 Started Container grid-firefox-1 Starting Container grid-chrome-1 Starting Container grid-firefox-1 Started Container grid-chrome-1 Started ``` `sbx exec` stops waiting after 30 seconds by default, so give the first `up` a longer `--timeout` while it pulls the images. Check the Grid with its `/status` endpoint. We used a small script that prints one line per node: ```python # status.py import json, urllib.request v = json.load(urllib.request.urlopen("http://localhost:4444/status"))["value"] print("ready:", v["ready"], "|", v["message"]) for n in v["nodes"]: browsers = sorted({s["stereotype"]["browserName"] + " " + s["stereotype"].get("browserVersion", "") for s in n["slots"]}) print(n["version"], n["availability"], "maxSessions", n["maxSessions"], browsers) ``` ```bash sbx scp status.py $id:/home/user/grid/status.py sbx exec $id "python3 grid/status.py" ``` ```text ready: True | Selenium Grid ready. 4.51.0 (revision 35c5fab …) UP maxSessions 1 ['firefox 158.0'] 4.51.0 (revision 35c5fab …) UP maxSessions 2 ['chrome 155.0'] ``` Three settings in the file matter: - `shm_size: 2gb`. The docker-selenium README asks for 2 GB of shared memory per browser container; Docker's default is 64 MB. - `SE_NODE_MAX_SESSIONS` with `SE_NODE_OVERRIDE_MAX_SESSIONS=true`. Each node runs one session by default. The README advises at most one session per CPU, so on a 4 vCPU sandbox keep the total across nodes at 4 or fewer. - `restart: unless-stopped`. When the sandbox boots from disk instead of restoring memory, Docker starts the Grid again (step 5). ## 2. Give the Grid a private HTTPS URL ```bash sbx host $id 4444 --title "Selenium Grid" ``` ```text https://apt-scarlet-ginger-4444.box.sandboxer.com?_token= ``` The URL is private. Anyone who runs tests through it can start browsers in your sandbox, so keep the token out of logs and CI output. ```bash curl -s -o /dev/null -w "%{http_code}\n" https://apt-scarlet-ginger-4444.box.sandboxer.com/status ``` ```text 401 ``` Opening the full URL in a browser answers `302` and sets the `__sbx_auth` cookie, so the Grid UI at `/ui/` loads normally after that (`200` in our test). How private URLs work is covered in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). ## 3. Run a remote WebDriver test against it A WebDriver client sends many requests to paths under the Grid address (`/session`, `/session//url` and so on). So give `webdriver.Remote` the bare address and send the token as the `__sbx_auth` cookie on every request. Selenium's `ClientConfig` takes extra headers for this ([Selenium HTTP client docs](https://www.selenium.dev/documentation/webdriver/drivers/http_client/)). ```python # remote_test.py: run one WebDriver test on the Grid in the sandbox import os, sys, time from urllib.parse import urlsplit, parse_qs from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.remote.client_config import ClientConfig hosted = os.environ["GRID_URL"] # the private URL from `sbx host`, with ?_token=… parts = urlsplit(hosted) token = parse_qs(parts.query)["_token"][0] grid = f"{parts.scheme}://{parts.netloc}" browser = sys.argv[1] if len(sys.argv) > 1 else "chrome" options = webdriver.ChromeOptions() if browser == "chrome" else webdriver.FirefoxOptions() config = ClientConfig(remote_server_addr=grid, extra_headers={"Cookie": f"__sbx_auth={token}"}) t0 = time.perf_counter() driver = webdriver.Remote(command_executor=grid, options=options, client_config=config) t1 = time.perf_counter() try: driver.get("https://www.selenium.dev/selenium/web/web-form.html") driver.find_element(By.NAME, "my-text").send_keys("hello from the grid") driver.find_element(By.CSS_SELECTOR, "button").click() message = driver.find_element(By.ID, "message").text t2 = time.perf_counter() caps = driver.capabilities print(f"{caps['browserName']} {caps['browserVersion']} | {driver.title} | {message}") print(f"new session {t1 - t0:.2f}s, fill + submit {t2 - t1:.2f}s") finally: driver.quit() ``` The page under test is the Selenium project's own demo form. Run it from your machine: ```bash pip install selenium # 4.51.0 in our run export GRID_URL='https://apt-scarlet-ginger-4444.box.sandboxer.com?_token=' python remote_test.py chrome python remote_test.py firefox ``` ```text chrome 155.0.8059.79 | Web form - target page | Received! new session 0.62s, fill + submit 1.41s firefox 158.0 | Web form - target page | Received! new session 1.85s, fill + submit 1.46s ``` The Grid picks the node from the browser in your options, so the same URL serves both browsers. ## 4. Where to run the test runner Every WebDriver call is an HTTP round trip to the Grid. We timed 20 calls to `driver.title` in one session, first from our machine through the hosted URL, then from inside the sandbox against `http://localhost:4444`: ```text apt-scarlet-ginger-4444: median 135.9 ms per command localhost:4444: median 6.8 ms per command ``` The gap is network distance, about 20 times per command in our test. Your number depends on where you are; the sandbox ran in `eu-central`. A test with 200 WebDriver calls spends about 27 seconds waiting on the network from outside, and about 1.4 seconds inside. Two ways to use this: | Situation | Run the tests from | Grid URL | | - | - | - | | Debugging one test from your IDE | Your machine | The private hosted URL, cookie header as above | | A full suite, or tests in CI | Inside the sandbox: `sbx scp` the tests in, `sbx exec` them | `http://localhost:4444`, no token | | Many suites at once | One sandbox per suite (see [Run coding agents in parallel](/guides/run-coding-agents-in-parallel)) | `localhost` in each | Inside the sandbox, Python, `uv` and Docker are already there. `uv venv .venv && uv pip install --python .venv/bin/python selenium` took a few seconds in our run. ### More sessions than slots We started 4 Chrome tests at once against the 2 Chrome slots. All 4 passed. The Grid queued the extra session requests and started them as slots freed up, so new-session times ranged from 0.69 s to 6.32 s, and the batch took 7.9 s. The README describes the queue timeout settings (`SE_SESSION_REQUEST_TIMEOUT`) if your suites queue for longer. ## 5. Watch a test through noVNC Each node image serves a noVNC page on port 7900. The compose file publishes the Chrome node's; host it like the Grid: ```bash sbx host $id 7900 --title "Chrome node VNC" ``` Without the token it returned `401`. With it, the noVNC page loaded, and the WebSocket handshake to `/websockify` returned `101` with the cookie. The VNC password is `secret` unless you set `SE_VNC_PASSWORD` ([docker-selenium VNC docs](https://github.com/SeleniumHQ/docker-selenium#using-your-browser-no-vnc-client-is-needed)). The hosted URL's token stays the first lock; the VNC password is a second one. This view shows the browsers inside the node container. It is separate from the sandbox desktop (`sbx desktop`), which [Run Browser Use or Playwright with real Chrome](/guides/run-browser-use-or-playwright-with-real-chrome) uses with the Chrome installed on the VM itself. ## 6. Stop, resume and cold boot **Stop and resume with memory.** `sbx stop $id --wait` took 2.2 s and `sbx resume $id` 1.3 s. The sandbox showed `memoryRestored: true`. The three containers had the same uptime as before the stop, the Grid was ready, and the remote Chrome and Firefox tests passed again on the same URL and token. A stopped sandbox costs nothing for compute. **Boot from disk.** Memory is restored only on a machine with the same CPU type; otherwise the sandbox boots from disk. We forced that by resuming on another size (`sbx resume $id --type large`, which reported `memoryRestored: false`, `resumeReason: "resized"`): ```text grid-chrome-1 Up 5 seconds grid-firefox-1 Up 5 seconds grid-selenium-hub-1 Up 5 seconds ready: True | Selenium Grid ready. ``` Docker started all three containers again because of `restart: unless-stopped`. The hosted route and token were kept. Our first remote test right after the boot got the proxy's "Nothing is answering on port 4444" page, because the hub wasn't listening yet; a retry a few seconds later passed. In CI, poll `/status` until `ready` is true before you start the suite. Moving to a smaller size isn't supported yet, so size the sandbox for the Grid you want from the start. ## FAQ ### Can I use `selenium/standalone-chrome` instead of a hub and nodes? Yes. The README's quick start runs one standalone container on port 4444 with `--shm-size="2g"`. Host port 4444 the same way and the client code doesn't change. We tested the hub-and-nodes layout because it runs Chrome and Firefox behind one URL. ### Why not put the `?_token=` URL straight into `command_executor`? The client adds paths such as `/session` to the address you give it, and the token belongs in the query string, not in the path. Pass the bare address and send the token as the `__sbx_auth` cookie, as in step 3. For a public Grid (`sbx host $id 4444 --public`) no cookie is needed, but then anyone with the URL can start browsers in your sandbox. ### How many parallel browsers fit in a `default` sandbox? The docker-selenium README recommends at most one browser session per CPU, which is 4 on a `default` sandbox. Our idle Grid used about 1.5 GB of the 8 GB. If a browser runs out of memory, its process exits with code 137; see [Fix exit code 137 or a full disk](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox). ### Did any request fail? Two did in about 20 remote runs on October 27, 2026. One came right after a cold boot, before the hub was listening (step 6). The other was a rare 502 during a platform update; retry after a few seconds. The retry passed and the next 15 runs all passed. Poll `/status` before a suite starts. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Run Browser Use or Playwright with real Chrome](/guides/run-browser-use-or-playwright-with-real-chrome) - [Hosting](/docs/hosting) --- # How to Batch Convert DOCX to PDF with LibreOffice Headless Source: https://sandboxer.com/guides/convert-documents-with-libreoffice-headless. Updated 2026-10-27. **Short answer:** Install the no-GUI LibreOffice packages with apt, then pass all your files to one soffice --headless --convert-to pdf call. In our test, 50 DOCX files took 6 seconds that way, 51 seconds as one call per file, and 2.6 seconds split across 4 processes, each with its own profile. Every number on this page comes from one `default` sandbox (4 vCPU, 8 GB) on prod on 2026-10-27. The test files were 50 generated invoices: 3 pages each, 12 paragraphs, a 21-row table, Calibri as the body font. ## Results at a glance | Method | 50 DOCX files to PDF | Per file | | - | - | - | | One `soffice` call per file, in a loop | 50.9 s to 63.9 s | about 1 s | | One `soffice` call with all 50 files | 5.8 s to 6.3 s | 0.12 s | | 4 `soffice` calls at once, separate profiles | 2.6 s to 2.7 s | 0.05 s | | Gotenberg over a private HTTPS URL, 1 file | 0.5 s to 1.3 s per request | | | Gotenberg over HTTPS, 19 files in one request | 5.5 s (zip back) | 0.29 s | Each range is two runs. The loop is slow because every call starts LibreOffice again. Starting it once and feeding it the whole batch is 8 to 10 times faster. ## 1. Install LibreOffice without the desktop LibreOffice is not in the [base image](/guides/whats-preinstalled-in-a-sandbox). Ubuntu 24.04 ships `-nogui` builds of each LibreOffice app, which skip the desktop UI. Add `poppler-utils` for `pdfinfo` and `pdffonts`, which you use to check the output. ```bash sbx new --type default --name docs-convert id=sb_imhk8ycbqk # the id that sbx new printed sbx exec $id --timeout 600 "sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libreoffice-writer-nogui libreoffice-calc-nogui libreoffice-impress-nogui poppler-utils && soffice --version" # LibreOffice 24.2.7.2 420(Build:2) # real 0m10.684s ``` The install took under 11 seconds and used about 400 MB of disk (`/` went from 5.5G to 5.9G used). Writer handles DOCX, Calc handles XLSX and Impress handles PPTX. Leave out the ones you don't need. ## 2. Convert one file, then check the fonts The commands in steps 2 to 4, and the `docker` and `curl localhost` lines in step 5, run inside the sandbox, for example in an `sbx ssh $id` session. ```bash cd ~/lo soffice --headless --convert-to pdf --outdir first docs/invoice-001.docx # convert /home/user/lo/docs/invoice-001.docx as a Writer document -> /home/user/lo/first/invoice-001.pdf using filter : writer_pdf_Export # real 0m1.109s pdfinfo first/invoice-001.pdf | grep Pages # Pages: 3 pdffonts first/invoice-001.pdf | tail -n +3 # BAAAAA+DejaVuSans TrueType WinAnsi yes yes yes 219 0 ``` The PDF is valid, but the font is wrong. The documents ask for Calibri, which isn't installed, so LibreOffice fell back to DejaVu Sans. DejaVu is wider, so line breaks and page counts can drift from what the author saw in Word. Install the metric-compatible fonts. Carlito matches Calibri's widths and Caladea matches Cambria's: ```bash fc-match Calibri # DejaVuSans.ttf: "DejaVu Sans" "Book" sudo apt-get install -y -qq fonts-crosextra-carlito fonts-crosextra-caladea fc-match Calibri # Carlito-Regular.ttf: "Carlito" "Regular" soffice --headless --convert-to pdf --outdir first docs/invoice-001.docx pdffonts first/invoice-001.pdf | tail -n +3 # BAAAAA+Carlito-Regular TrueType WinAnsi yes yes yes 219 0 ``` Run `pdffonts` on a sample of your real documents before a big batch. Any font it lists that isn't the one the document asked for is a substitution. You will also see `Warning: failed to launch javaldx - java may not function correctly` on every run. It is harmless for conversions: we skipped Java on purpose with `--no-install-recommends`. ## 3. Convert a whole folder in one call Pass every file to a single `soffice`. It starts once and converts them in order: ```bash soffice --headless --convert-to pdf --outdir out-batch docs/*.docx ls out-batch | wc -l # 50 ``` That took 5.8 to 6.3 seconds for 50 files, against 51 to 64 seconds for a `for` loop that called `soffice` once per file. ## 4. Run several converters at once, each with its own profile `soffice` keeps its settings in a profile directory (`~/.config/libreoffice` by default), and only one process can use a profile at a time. We started two batches at once with the default profile: ```bash (soffice --headless --convert-to pdf --outdir clash docs/invoice-00[1-5].docx; echo "a exit=$?") & (soffice --headless --convert-to pdf --outdir clash docs/invoice-01[0-4].docx; echo "b exit=$?") & wait # b exit=1 # a exit=0 ls clash/*.pdf | wc -l # 5 ``` The second process exited with status 1, printed nothing but the Java warning and converted nothing. Five of the ten files are missing. If you parallelize without separate profiles, check exit codes and file counts, or you will lose files quietly. The fix is `-env:UserInstallation`, which gives each process its own profile. This splits the 50 files into 4 groups, one per vCPU: ```bash ls docs/*.docx | split -n r/4 -d - /tmp/chunk. for i in 0 1 2 3; do soffice -env:UserInstallation=file:///tmp/lo-profile-$i \ --headless --convert-to pdf --outdir out-par $(cat /tmp/chunk.0$i) & done wait ls out-par | wc -l # 50 ``` All 50 PDFs arrived in 2.6 to 2.7 seconds, about 2.3 times faster than one batch call. Match the number of groups to the sandbox's vCPUs (`nproc`). For thousands of files, start the script with `sbx exec --detach` and check on it later, as in [run a long job in a sandbox](/guides/run-a-long-job-in-a-cloud-sandbox). ## 5. Serve conversions over HTTPS with Gotenberg If other services need to send documents in, run [Gotenberg](https://gotenberg.dev/docs/getting-started/introduction), an open-source HTTP API around LibreOffice and Chromium, as a container. Docker is already in the sandbox. `--restart unless-stopped` tells Docker to start the container again whenever the Docker daemon starts. ```bash docker run -d --name gotenberg --restart unless-stopped -p 3000:3000 gotenberg/gotenberg:8 curl -s localhost:3000/health # {"status":"up","details":{"chromium":{"status":"up",…},"libreoffice":{"status":"up",…}}} ``` The pull took 20 seconds and the image is 1.7 GB. It brings its own LibreOffice (26.8.1.1 on the day we tested), separate from the apt one. Give the port a [private HTTPS URL](/docs/hosting) and call the [LibreOffice route](https://gotenberg.dev/docs/convert-with-libreoffice/convert-to-pdf) from your laptop: ```bash sbx host $id 3000 # "url":"https://sharp-fancy-plum-3000.box.sandboxer.com?_token=…","access":"private" curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST \ https://sharp-fancy-plum-3000.box.sandboxer.com/forms/libreoffice/convert \ -F files=@invoice-007.docx # HTTP 401 TOKEN=… # the _token value from the URL above; keep it out of logs curl -s -o inv7.pdf -w "HTTP %{http_code} %{size_download} bytes %{time_total}s\n" -X POST \ "https://sharp-fancy-plum-3000.box.sandboxer.com/forms/libreoffice/convert?_token=$TOKEN" \ -F files=@invoice-007.docx # HTTP 200 21720 bytes 1.330897s # HTTP 200 21720 bytes 0.641700s # HTTP 200 21720 bytes 0.503791s ``` Without the token the proxy answers 401 before Gotenberg sees the request. The first converted request took 1.3 seconds, later ones about 0.5 seconds, round trip from our test machine. Send several `files` fields in one request to get a zip of PDFs. Our 19 files came back in 5.5 seconds as a zip named `invoice-001.docx.pdf` and so on. Add `-F merge=true` to get one merged PDF instead: 57 pages in 5.6 seconds. The private URL's token works like a password. Keep it in a secret store and out of logs. ## What survives a stop and resume We ran `sbx stop $id --wait` (2.2 seconds), then `sbx resume $id` (1.4 seconds). The resume reported `"memoryRestored": true`, so the Gotenberg container was still running. The apt packages, fonts and all 50 PDFs were still on disk. The next HTTPS conversion returned 200 in 0.67 seconds. When a resume can't restore memory, for example on a host with a different CPU type, the sandbox boots from disk. Your files and packages are kept, and running processes are not. The Docker daemon starts at boot, and the `--restart unless-stopped` policy then brings Gotenberg back. A plain `soffice` batch that was mid-run would need starting again, so write your batch script so it skips PDFs that already exist. ## FAQ ### Why does LibreOffice headless exit with status 1 and convert nothing? Usually another `soffice` process is already using the same profile. That includes a hung earlier run. Give each concurrent process its own `-env:UserInstallation=file:///tmp/lo-profile-N`, or check for a leftover `soffice.bin` with `pgrep soffice`. ### Why does my PDF look different from Word? The document uses fonts the sandbox doesn't have, so LibreOffice substitutes them. `pdffonts` shows what was used. Install `fonts-crosextra-carlito` and `fonts-crosextra-caladea` for Calibri and Cambria, and add any other fonts your documents need to `~/.fonts` or `/usr/share/fonts`. ### Which sandbox size should I use? The batch numbers above are from `default`, with 4 vCPU. `small` has 2 vCPU, so run 2 parallel groups instead of 4. Bigger sizes give more vCPUs for more parallel groups; see [Machines & sizes](/docs/machines). ### Should I use soffice directly or Gotenberg? Use `soffice` for batch jobs inside the sandbox; it was faster in our test and needs no container. Use Gotenberg when other programs need to send documents over HTTP, or when you want merging and a health check without writing them yourself. ## Related - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Hosting docs](/docs/hosting) --- # How to Build an FFmpeg Video Pipeline in a Cloud Sandbox Source: https://sandboxer.com/guides/build-an-ffmpeg-video-pipeline-in-a-sandbox. Updated 2026-10-27. **Short answer:** FFmpeg 6.1.1 with x264, x265, SVT-AV1, VP9 and Opus is preinstalled in every sandbox, so a pipeline is one script. On 60 seconds of 1080p video, a 720p H.264 encode took 18 s on small and 13 s on default. A full 10.5-minute film took 188 s and 113 s. ## What we ran Everything below ran on prod on October 27, 2026, in two sandboxes: one `small` (2 vCPU, 4 GB, $0.018/hour) and one `default` (4 vCPU, 8 GB, $0.036/hour). The source is [Big Buck Bunny](https://peach.blender.org/) (Blender Foundation, CC BY 3.0): 1080p30 H.264, 634.6 seconds. Each timing is a single run, so treat the numbers as a guide, not a promise. | Step | `small` | `default` | | - | - | - | | Download the 263 MB source inside the sandbox | 3.1 s | not timed | | Full pipeline on a 60 s clip (720p MP4, 2-rung HLS, thumbnails, poster) | 47.8 s | 26.2 s | | 720p H.264 `veryfast`, 60 s clip | 18.0 s (3.3x realtime) | 13.2 s (4.6x) | | 720p H.264 `veryfast`, whole 634.6 s film | 188.3 s (3.4x) | 113.2 s (5.6x) | | 1080p AV1 (SVT-AV1 preset 8), 60 s clip | 133.3 s (0.45x) | 85.7 s (0.70x) | | Hosted HLS output on a private URL | 401 without the token, 200 with it | | | `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, the file server kept serving | | ## 1. Check what FFmpeg can encode You don't install anything. FFmpeg comes with the image ([what's preinstalled](/guides/whats-preinstalled-in-a-sandbox)), built with the encoders most pipelines need: ```bash id=$(sbx new --type small --json | jq -r 'select(.event == "ready") | .id') sbx exec $id 'ffmpeg -version | head -1; ffmpeg -hide_banner -encoders 2>/dev/null | grep -E "libx264|libx265|libsvtav1|libvpx-vp9|libopus|libmp3lame"' ``` ```text ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers V..... libsvtav1 SVT-AV1(Scalable Video Technology for AV1) encoder (codec av1) V....D libx264 libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (codec h264) V....D libx264rgb libx264 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 RGB (codec h264) V....D libx265 libx265 H.265 / HEVC (codec hevc) V....D libvpx-vp9 libvpx VP9 (codec vp9) A....D libmp3lame libmp3lame MP3 (MPEG audio layer 3) (codec mp3) A....D libopus libopus Opus (codec opus) ``` There is no GPU, so there is no NVENC or VAAPI. Every encode on this page is on the CPU. ## 2. Pull the source into the sandbox Fetch inputs from inside the sandbox rather than uploading them from your laptop. The sandbox downloaded the 263 MB file in 3.1 seconds: ```bash sbx exec $id --timeout 600 'cd ~ && curl -sSfL -o bbb.zip https://download.blender.org/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4.zip \ && unzip -q bbb.zip && rm bbb.zip && mv bbb_sunflower_1080p_30fps_normal.mp4 bbb-full.mp4 \ && ffmpeg -hide_banner -loglevel error -y -i bbb-full.mp4 -t 60 -map 0:v -map 0:a:0 -c copy clip60.mp4 \ && ffprobe -v error -show_entries format=duration,bit_rate:stream=codec_type,codec_name,width,height -of compact bbb-full.mp4' ``` ```text stream|codec_name=h264|codec_type=video|width=1920|height=1080 stream|codec_name=mp3|codec_type=audio stream|codec_name=ac3|codec_type=audio format|duration=634.600000|bit_rate=3481058 ``` The file has two audio tracks, so the commands below map the first one (`0:a:0`) on purpose. Otherwise FFmpeg picks one for you. `sbx exec` stops synchronous commands after 30 seconds unless you pass `--timeout` (600 at most). Use `--detach` for anything longer (step 5). ## 3. The pipeline script One script turns an upload into what a video page needs: a probe, a 720p MP4 for download, an HLS ladder (720p and 360p) for streaming, a thumbnail sheet and a poster frame. It prints how long each step took. ```bash #!/usr/bin/env bash # pipeline.sh : probe, 720p MP4, 2-rung HLS ladder, poster and thumbnail sheet set -euo pipefail in=$1; out=$2; mkdir -p "$out/hls" t() { local s=$(date +%s.%N); "$@" ; awk -v a="$s" -v b="$(date +%s.%N)" -v n="$step" 'BEGIN{printf "%-7s %6.1f s\n", n, b-a}' >&2; } q="-hide_banner -loglevel error -y" step=probe t ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json "$in" > "$out/probe.json" step=mp4 t ffmpeg $q -i "$in" -vf scale=-2:720 -c:v libx264 -preset veryfast -crf 23 \ -c:a aac -b:a 128k -movflags +faststart "$out/720p.mp4" step=hls t ffmpeg $q -i "$in" -filter_complex "[0:v]split=2[a][b];[a]scale=-2:720[v720];[b]scale=-2:360[v360]" \ -map "[v720]" -map "[v360]" -map 0:a -map 0:a \ -c:v libx264 -preset veryfast -g 60 -keyint_min 60 -sc_threshold 0 \ -b:v:0 3000k -maxrate:v:0 3300k -bufsize:v:0 6000k \ -b:v:1 800k -maxrate:v:1 880k -bufsize:v:1 1600k -c:a aac -b:a 128k \ -f hls -hls_time 4 -hls_playlist_type vod -master_pl_name master.m3u8 \ -var_stream_map "v:0,a:0 v:1,a:1" -hls_segment_filename "$out/hls/%v_%03d.ts" "$out/hls/%v.m3u8" step=thumbs t ffmpeg $q -i "$in" -vf "fps=1/2,scale=320:-2,tile=5x2" -frames:v 1 "$out/thumbs.jpg" step=poster t ffmpeg $q -ss 5 -i "$in" -frames:v 1 "$out/poster.jpg" ``` The HLS step decodes the source once and splits it into both renditions. `-g 60 -sc_threshold 0` puts a keyframe every 2 seconds, so every 4-second segment starts on one. Each flag is in the [FFmpeg formats docs](https://ffmpeg.org/ffmpeg-formats.html#hls-2). ```bash sbx scp ./pipeline.sh $id:/home/user/ sbx exec $id --timeout 600 'chmod +x ~/pipeline.sh && cd ~ && ./pipeline.sh clip60.mp4 out && cat out/hls/master.m3u8' ``` On `small`: ```text #EXTM3U #EXT-X-VERSION:3 #EXT-X-STREAM-INF:BANDWIDTH=3440800,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" 0.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" 1.m3u8 probe 0.1 s mp4 20.0 s hls 25.3 s thumbs 2.0 s poster 0.4 s ``` On `default`, the same script took 0.0, 9.7, 14.9, 1.2 and 0.4 seconds. (The `small` run above predates the bitrate cap described next; the `default` run includes it.) **Cap the bitrate.** Our first `small` run had only `-b:v:0 3000k`. One 4-second 720p segment came out at 4.4 MB, about 8.9 Mbit/s, roughly three times the bitrate the playlist advertises. That can make players pick a rendition the viewer's connection can't sustain. With `-maxrate` and `-bufsize` added (as in the script above), the same segment was 2.0 MB. ## 4. Encode speed per size We ran the same benchmark script on both sizes. "Realtime" is seconds of video divided by seconds of encoding. Output sizes matched within 0.5 MB across sizes: the CPU count changes speed, not the result. | Encode (60 s of 1080p30 unless noted) | `small` | `default` | Output | | - | - | - | - | | 720p H.264, `-preset veryfast -crf 23` | 18.0 s, 3.33x | 13.2 s, 4.56x | 9.6 MB | | 720p H.264, `-preset medium -crf 23` | 37.8 s, 1.59x | 21.2 s, 2.83x | 13.3 MB | | 1080p H.264, `-preset veryfast -crf 23` | 36.7 s, 1.64x | 19.5 s, 3.08x | 18.0 MB | | 1080p H.265, `-preset fast -crf 28` | 86.9 s, 0.69x | 42.1 s, 1.42x | 10.3 MB | | 1080p AV1, SVT-AV1 `-preset 8 -crf 35` | 133.3 s, 0.45x | 85.7 s, 0.70x | 13.1 MB | | Whole film (634.6 s) to 720p H.264 `veryfast` | 188.3 s, 3.37x | 113.2 s, 5.61x | 110 MB | What it costs: at those whole-film speeds, one hour of source video to 720p H.264 takes about 18 minutes on `small` (about $0.005) and about 11 minutes on `default` (about $0.006). `small` is slightly cheaper per minute of video; `default` finishes sooner. Pick `default` when someone is waiting for the result, and `small` for an overnight backlog. Prices come from the [pricing page](/pricing). The `default` sandbox ran on an AMD Ryzen 9 7950X3D host. We didn't record the `small` sandbox's host CPU, so part of the gap may come from the host, not only the vCPU count. ## 5. Run several encodes at once, or detach long ones x264 doesn't use 4 vCPUs fully on a single 720p encode. On `default`, four 720p `veryfast` encodes of the 60-second clip took: | How | Wall time | | - | - | | One after another (4 x 13.2 s) | 52.8 s | | All four at once with `&` and `wait` | 39.1 s | | All four at once, each with `-threads 1` | 34.8 s | For a batch, run one FFmpeg per vCPU with `-threads 1` instead of one big multi-threaded job. The whole-film encodes ran longer than `sbx exec` waits, so we started the benchmark detached and polled it: ```bash pid=$(sbx exec $id --detach 'bash ~/bench.sh' | jq -r .processId) while [ "$(sbx exec $id --status $pid --json | jq -r .running)" = true ]; do sleep 30; done sbx exec $id --status $pid --json | jq -r '.exitCode, .stdout' ``` ```text 0 720p x264 veryfast 13.2 s 4.56x realtime rc=0 … full film 720p veryfast 113.2 s 5.61x realtime rc=0 ``` A running detached job counts as activity for idle pause, but it doesn't hold off auto-stop. For jobs that take hours, see [run a long job and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox). ## 6. Serve the HLS output on a private URL To play the result in a browser or check it with a real player, serve the output folder and host the port. A systemd unit restarts the server if it crashes, and again on any boot: ```ini # hls-web.service [Unit] Description=Serve ~/out over HTTP for HLS playback After=network.target [Service] User=user WorkingDirectory=/home/user/out ExecStart=/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0 Restart=always [Install] WantedBy=multi-user.target ``` ```bash sbx scp ./hls-web.service $id:/tmp/ sbx exec $id 'sudo mv /tmp/hls-web.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now hls-web && systemctl is-active hls-web' URL=$(sbx host $id 8080 --title "HLS output" --json | jq -r .url) # private by default ``` The URL is `https://-8080.box.sandboxer.com?_token=…`. Keep it out of logs. We checked it from a laptop: ```bash BASE=${URL%%\?*} curl -s -o /dev/null -w '%{http_code}\n' "$BASE/hls/master.m3u8" # no token curl -s -c jar -b jar -L -o /dev/null -w '%{http_code} after %{num_redirects} redirect\n' \ "$BASE/hls/master.m3u8?_token=${URL##*_token=}" # token sets a cookie curl -s -b jar -o /dev/null -w '%{http_code} %{size_download} bytes %{time_total}s\n' "$BASE/hls/0_001.ts" ``` ```text 401 200 after 1 redirect 200 4430972 bytes 2.072551s ``` The first request with `_token` returns a `302` that sets a cookie. After that, plain paths work, which is what a player needs when it fetches `0.m3u8` and the segments relative to the master playlist. That 4.4 MB segment (from the uncapped first run) took about 2 seconds to download from our laptop, so playback keeps ahead. Your network sets that number. For a public demo, `sbx host $id 8080 --public` drops the token. See [give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) for the details. ## 7. What survives a stop ```bash sbx stop $id --wait sbx resume $id sbx info $id --json | jq '.sandbox | {memoryRestored, stop: .stop.reason}' ``` ```json { "memoryRestored": true, "stop": "user" } ``` The output files, the file server and the hosted URL all came back: the same segment URL returned `200` with the same 4,430,972 bytes. If a resume lands on a machine with a different CPU type, the sandbox boots from disk (`memoryRestored: false`). Your files stay, but running processes are lost. The enabled `hls-web` unit starts again on that boot. A half-finished FFmpeg job doesn't, so rerun it. We didn't trigger a cold boot in this test. ## 8. Get the results out ```bash sbx scp $id:/home/user/out/720p.mp4 . ``` The 10.1 MB file took 5.3 seconds to copy to our laptop, tunnel setup included. For whole folders, `rsync` and the SDK limits, see [move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox). Delete the sandbox when you're done: `sbx delete $id --yes`. ## FAQ ### Is FFmpeg already installed in a Sandboxer sandbox? Yes. Version 6.1.1 from Ubuntu 24.04, with libx264, libx265, SVT-AV1, libvpx-vp9, Opus and MP3. ### Which sandbox size should I use for transcoding? `default` encoded 1.4x to 2.1x faster than `small` in our runs, and cost a little more per minute of video. Use `default` when a user is waiting, and `small` for batch backlogs. Encoding to AV1 or H.265 at 1080p ran below realtime on `small`. ### Can I use GPU encoding such as NVENC? No. Sandboxes have no GPU, so use the CPU encoders. For H.264 at 720p, `veryfast` ran at 3.3x to 5.6x realtime in our tests. ### How do I process files longer than the exec timeout? Start FFmpeg with `sbx exec --detach`, which returns a process id at once, then check `sbx exec --status`. Synchronous `exec` stops at 600 seconds even with `--timeout`. ### Can a browser play the HLS output straight from the sandbox? Yes. Host the port that serves the folder. With a private URL, the first request needs `?_token=`. It sets a cookie, so the player's follow-up requests for playlists and segments work without the token. ## Related - [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [What's preinstalled in a sandbox](/guides/whats-preinstalled-in-a-sandbox) --- # How to Render HTML to PDF With Headless Chrome in a Sandbox Source: https://sandboxer.com/guides/render-html-to-pdf-with-headless-chrome. Updated 2026-10-27. **Short answer:** Every Sandboxer VM has Google Chrome, so google-chrome --headless --print-to-pdf works right away. Add --virtual-time-budget so page scripts finish, and install fonts-noto-cjk for Asian text. For page numbers, margins and waiting on a selector, use Playwright's page.pdf(). One browser printed 50 pages in about 2 seconds. Every output and timing on this page comes from one `default` sandbox (4 vCPU, 8 GB) on prod on 2026-10-27, with Google Chrome 155.0.8059.39 and Playwright 1.64.1. This page is about printing HTML. To drive a browser or watch it, see [Run Browser Use or Playwright with real Chrome](/guides/run-browser-use-or-playwright-with-real-chrome). For DOCX, XLSX and PPTX, see [LibreOffice headless](/guides/convert-documents-with-libreoffice-headless). ## Results at a glance | What we tried | Result | | - | - | | `--print-to-pdf`, no flags | 0.7 s, but the JavaScript-rendered table is missing and a date/URL header is added | | `--print-to-pdf --virtual-time-budget=5000 --no-pdf-header-footer` | 0.7 s, full table, no header | | Japanese text, base image | Empty boxes (no CJK font installed) | | Japanese text after `apt-get install fonts-noto-cjk` | Correct glyphs; install took 3.2 s | | Playwright `page.pdf()` with no options | Letter paper, even though the CSS asks for A4; backgrounds dropped | | 50 HTML files, one `google-chrome` call per file | 28.5 s to 29.3 s | | 50 HTML files, one Playwright browser, one page | 2.1 s to 2.3 s | | Same, 4 or 8 pages at once | 2.4 s to 3.2 s (slower for these small pages) | | PDF service on a private HTTPS URL | 401 without the token, 200 with it, 2.3 s per request | ## 1. The test page The page sets A4 in CSS, has a line of Japanese, and builds its table with JavaScript 1.5 seconds after load, the way a dashboard that fetches data would: ```html Q3 report

Q3 report

東京オフィスの売上

Loading…
``` Copy a folder into the sandbox with tar over `sbx exec -i`, then install `poppler-utils` for `pdfinfo`, `pdftotext` and `pdffonts`, which you use to check the output: ```bash sbx new --type default id=sb_… # the id that sbx new printed tar cz -C pdf . | sbx exec -i $id "mkdir -p ~/pdf && tar xz -C ~/pdf" sbx exec $id --timeout 300 "sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends poppler-utils" ``` The commands in steps 2 to 5 run inside the sandbox, in `~/pdf`. Use `sbx ssh $id`, or wrap each one in `sbx exec $id "…"`. ## 2. Chrome's command line, and the wait problem ```bash google-chrome --headless --print-to-pdf=cli-plain.pdf file://$PWD/report.html # …ERROR:dbus/bus.cc:405] Failed to connect to the bus: … # 21287 bytes written to file cli-plain.pdf pdftotext cli-plain.pdf - | head -4 # 10/27/26, 10:45 AM # # Q3 report ``` Two problems. Chrome prints as soon as the page loads, so the PDF has `Loading…` where the table should be: one page, zero table rows. And it adds a header and footer with the date, title, URL and page number. The `dbus` lines are harmless log noise. `--virtual-time-budget` lets the page's timers and network run before printing. Chrome fast-forwards timers in virtual time, so a 5,000 ms budget doesn't cost 5 seconds of real time. `--no-pdf-header-footer` removes the date and URL: ```bash time google-chrome --headless --no-pdf-header-footer --virtual-time-budget=5000 \ --print-to-pdf=cli-wait.pdf file://$PWD/report.html # 58660 bytes written to file cli-wait.pdf # real 0m0.679s pdfinfo cli-wait.pdf | grep -E "Pages|Page size" # Pages: 2 # Page size: 594.96 x 841.92 pts (A4) ``` Now the table is there, over 2 pages. The command line honored the CSS `@page { size: A4 }` and printed the blue table header. It has no flag to wait for a specific element, so pick a budget longer than your slowest page needs, or use Playwright (step 4). ## 3. Fonts: check before you trust the output The base image has 21 fonts and none covers Japanese, Chinese or Korean. The first PDF showed the Japanese line as a row of empty boxes. `pdftotext` still extracts the characters, so a text check won't catch it; render a page to an image (`pdftoppm -png`) or check `fc-list :lang=ja`, which printed nothing. ```bash sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends fonts-noto-cjk # real 0m3.246s fc-match sans-serif:lang=ja # NotoSansCJK-Regular.ttc: "Noto Sans CJK JP" "Regular" google-chrome --headless --no-pdf-header-footer --virtual-time-budget=5000 \ --print-to-pdf=cli-cjk.pdf file://$PWD/report.html pdffonts cli-cjk.pdf | tail -n +3 # AAAAAA+LiberationSans-Bold CID TrueType Identity-H yes yes yes 4 0 # BAAAAA+NotoSansCJKjp-Regular Type 3 Custom yes yes yes 5 0 # … ``` The re-rendered page showed 東京オフィスの売上 correctly. Install your brand fonts the same way, or copy `.ttf` files into `~/.fonts` and run `fc-cache`. Web fonts loaded with `@font-face` from a URL also need the page to wait until they've loaded, which is one more reason to wait properly before printing. ## 4. Playwright `page.pdf()`: wait for an element, set the paper Playwright uses the installed Chrome with `channel: "chrome"`, so `npm i playwright` is all you install; there's no browser download. `waitForSelector` waits for exactly the element you need instead of guessing a time budget. Options are in [Playwright's page.pdf() docs](https://playwright.dev/docs/api/class-page#page-pdf). ```javascript // pdf.mjs: wait for the JS-rendered table, then print with a page-number footer import { chromium } from "playwright"; const browser = await chromium.launch({ channel: "chrome" }); const page = await browser.newPage(); await page.goto("file:///home/user/pdf/report.html"); await page.waitForSelector("#data"); // the content we need is there await page.pdf({ path: "default.pdf" }); // no options await page.pdf({ path: "report-pw.pdf", format: "A4", margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" }, printBackground: true, // keep the table header color displayHeaderFooter: true, headerTemplate: "", footerTemplate: `
Page of
`, }); await browser.close(); ``` ```bash npm init -y && npm i playwright time node pdf.mjs # real 0m2.829s for f in default report-pw; do echo == $f; pdfinfo $f.pdf | grep "Page size"; pdftotext $f.pdf - | grep "^Page [0-9]"; done # == default # Page size: 612 x 792 pts (letter) # == report-pw # Page size: 595.92 x 842.88 pts (A4) # Page 1 of 2 # Page 2 of 2 ``` The 2.8 seconds includes the page's own 1.5-second timer. Compare the two PDFs: - **Paper size.** With no options, `page.pdf()` printed on US Letter and ignored the CSS `@page { size: A4 }`. Pass `format`, or `preferCSSPageSize: true` to let the CSS decide. - **Backgrounds.** The default dropped background colors, so the blue table header was gone and its white text was barely visible. `printBackground: true` brings them back. - **Header and footer.** `displayHeaderFooter` with templates gives page numbers. Set a `headerTemplate` too, or you get the default date and title header. Leave room with `margin`, or the footer draws over your content. ## 5. Batch: one browser, not one Chrome per file We generated 50 two-page invoice HTML files and converted them three ways. Each row is two runs. ```javascript // batch.mjs: one browser, N pages working through a folder of HTML files import { chromium } from "playwright"; import { readdirSync, mkdirSync } from "node:fs"; const [dir, out, n = "1"] = process.argv.slice(2); mkdirSync(out, { recursive: true }); const files = readdirSync(dir).filter((f) => f.endsWith(".html")); const t0 = Date.now(); const browser = await chromium.launch({ channel: "chrome" }); async function worker() { const page = await browser.newPage(); while (files.length) { const f = files.shift(); await page.goto(`file://${dir}/${f}`); await page.pdf({ path: `${out}/${f.replace(".html", ".pdf")}`, format: "A4" }); } await page.close(); } await Promise.all(Array.from({ length: Number(n) }, worker)); await browser.close(); console.log(`${readdirSync(out).length} PDFs, ${n} page(s), ${((Date.now() - t0) / 1000).toFixed(1)} s`); ``` ```bash node batch.mjs $PWD/in out-1 1 # 50 PDFs, 1 page(s), 2.3 s node batch.mjs $PWD/in out-4 4 # 50 PDFs, 4 page(s), 2.5 s node batch.mjs $PWD/in out-8 8 # 50 PDFs, 8 page(s), 3.2 s ``` | Method | 50 files | Per file | | - | - | - | | `google-chrome --print-to-pdf`, one call per file | 28.5 s to 29.3 s | about 0.6 s | | Playwright, one browser, 1 page | 2.1 s to 2.3 s | about 0.04 s | | Playwright, one browser, 4 pages | 2.4 s to 2.5 s | | | Playwright, one browser, 8 pages | 2.9 s to 3.2 s | | | 4 Node processes, one browser each | 3.0 s | | Starting Chrome is the expensive part, so a loop that launches it for every file is about 13 times slower. For these small static pages, extra pages or processes made things slower, not faster. Concurrency can help when each page spends its time waiting on the network or its own scripts, so measure with your real pages before you add it. The command-line and Playwright PDFs of the same invoice had identical text. For a long batch, start it with `sbx exec --detach` and check on it later, as in [run a long job in a sandbox](/guides/run-a-long-job-in-a-cloud-sandbox). ## 6. Optional: a small PDF service on a private URL If another program needs to send HTML in, run a tiny HTTP server that keeps one browser open. The `?wait=` parameter takes a CSS selector to wait for: ```javascript // server.mjs: POST HTML, get a PDF back. One shared browser. import http from "node:http"; import { chromium } from "playwright"; const browser = await chromium.launch({ channel: "chrome" }); http.createServer(async (req, res) => { if (req.method !== "POST") { res.writeHead(405).end(); return; } let html = ""; for await (const chunk of req) html += chunk; const page = await browser.newPage(); try { await page.setContent(html, { waitUntil: "networkidle" }); // ?wait=: also wait for content your scripts add later const wait = new URL(req.url, "http://x").searchParams.get("wait"); if (wait) await page.waitForSelector(wait, { timeout: 15000 }); const pdf = await page.pdf({ format: "A4", printBackground: true }); res.writeHead(200, { "Content-Type": "application/pdf" }).end(pdf); } catch (e) { res.writeHead(500).end(String(e)); } finally { await page.close(); } }).listen(3000, "0.0.0.0", () => console.log("listening on 3000")); ``` Start it in the background and give port 3000 a [private HTTPS URL](/docs/hosting) from your computer: ```bash sbx exec $id --detach "cd ~/pdf && node server.mjs > server.log 2>&1" sbx host $id 3000 --json # {"access":"private","port":3000,"url":"https://olive-polar-cricket-3000.box.sandboxer.com?_token=…"} URL=https://olive-polar-cricket-3000.box.sandboxer.com TOKEN=… # the _token value from the URL above; keep it out of logs curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST --data-binary @report.html "$URL" # HTTP 401 curl -s -o out.pdf -w "HTTP %{http_code} %{size_download} bytes %{time_total}s\n" \ -X POST --data-binary @report.html "$URL?_token=$TOKEN" # HTTP 200 25433 bytes 1.014525s curl -s -o out.pdf -w "HTTP %{http_code} %{size_download} bytes %{time_total}s\n" \ -X POST --data-binary @report.html "$URL?_token=$TOKEN&wait=%23data" # HTTP 200 28504 bytes 2.295052s ``` Without the token, the proxy answers 401 before your server sees the request. The faster 200 is the wait trap again: `networkidle` returned before the page's 1.5-second timer, so the PDF said `Loading…`, one page and no table. With `wait=%23data` (`#data`, URL-encoded), the PDF had both pages and the table. Three requests with the wait took 2.29 s to 2.31 s, round trip from our test machine, most of it the page's own timer. The private URL's token works like a password. Keep it in a secret store and out of logs. Treat the HTML you accept as untrusted too: a page rendered here can fetch any URL the sandbox can reach. ## What survives a stop and resume We ran `sbx stop $id --wait` (2.2 seconds), then `sbx resume $id` (1.3 seconds). The memory was restored: `uptime` didn't reset, the server kept its process IDs, and the next request through the same private URL returned the full PDF in 2.3 seconds. The fonts, `node_modules` and PDFs on disk were kept. If a resume can't restore memory, for example on a host with a different CPU type, the sandbox boots from disk. Files and packages stay, but the Node server and its browser are gone, so start `server.mjs` again (or run it as a systemd service). See [detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume). ## FAQ ### Why is my PDF missing content that shows in a normal browser? Chrome printed before your JavaScript finished. On the command line add `--virtual-time-budget=5000` (or more). In Playwright, call `page.waitForSelector()` on an element that appears only when the page is ready, then call `page.pdf()`. ### Why does Playwright give me Letter paper when my CSS says A4? `page.pdf()` uses its own paper size unless you tell it otherwise. Pass `format: "A4"`, or `preferCSSPageSize: true` to use the CSS `@page` size. The `google-chrome --print-to-pdf` command line used the CSS size in our test. ### Why does Chinese, Japanese or Korean text come out as boxes? The sandbox image has no CJK font. Install `fonts-noto-cjk` with apt (3.2 seconds in our test), or copy your own fonts into `~/.fonts`. Check by rendering a page to PNG with `pdftoppm`, since `pdftotext` extracts the text even when the glyphs are missing. ### Do I need `playwright install` or a separate Chromium? No. Google Chrome is already installed, and `channel: "chrome"` makes Playwright use it. See [what's pre-installed in a sandbox](/guides/whats-preinstalled-in-a-sandbox). ## Related - [Run Browser Use or Playwright with real Chrome in a cloud VM](/guides/run-browser-use-or-playwright-with-real-chrome) - [Batch convert DOCX to PDF with LibreOffice headless](/guides/convert-documents-with-libreoffice-headless) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Hosting docs](/docs/hosting) --- # How Do I Run Home Assistant in a Cloud Sandbox to Try It Out? Source: https://sandboxer.com/guides/run-home-assistant-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official Home Assistant Container image in a Sandboxer sandbox and host port 8123 with sbx host. Write an http block that trusts the sandbox's gateway as a reverse proxy before the first start, or every request through the URL gets 400 Bad Request. Onboarding, the REST API and WebSockets then work. This is a place to try Home Assistant, build dashboards and test automations without a Raspberry Pi. It is not a replacement for a box at home: the sandbox is in a data center, not on your home network, and it has no USB, Zigbee or Bluetooth radio. ## At a glance Everything below ran on one `default` sandbox (4 vCPU, 8 GB RAM) on October 27, 2026. Single runs, so treat timings as rough. | Step | What we saw | | - | - | | Pull `ghcr.io/home-assistant/home-assistant:stable` | 2.35 GB image, 45 s | | Home Assistant version | `2026.10.4` | | `docker run` to first HTTP response | 5.2 s | | Hosted URL, default settings | `400: Bad Request` on every page | | Hosted URL, gateway trusted before first start | `302` to `/onboarding.html`, then `200` | | RAM in use after onboarding | 281 MiB | | REST service call through the URL (20 calls) | median 129 ms, p90 131 ms, from our test machine | | WebSocket API through the URL | `auth_ok`, `state_changed` events arrive | | `sbx stop --wait` + `sbx resume` | `memoryRestored: true`, same container PID | | Reboot | Home Assistant answering 3.7 s after boot, helper state restored | ## 1. Create a sandbox and pull the image ```bash sbx new --name home-assistant --type default id=sb_kuf6ceh6ps # the id that sbx new printed sbx exec $id --timeout 600 "docker pull -q ghcr.io/home-assistant/home-assistant:stable" ``` Docker is preinstalled. Pass `--timeout 600`: `sbx exec` stops waiting after 30 seconds by default, and the pull took 45. ## 2. Why the hosted URL returns 400 by default We first started Home Assistant exactly as its [Linux install page](https://www.home-assistant.io/installation/linux/#install-home-assistant-container) shows and hosted port 8123. The token URL set its cookie, then every page returned this: ```text withtoken 302 -> https://rare-even-atlas-8123.box.sandboxer.com/ cookie / 400 -> 400: Bad Request ``` The container log says why: ```text ERROR (MainThread) [homeassistant.components.http.forwarded] A request from a reverse proxy was received from 10.202.0.69, but your HTTP integration is not set-up for reverse proxies ``` The Sandboxer proxy adds `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` to each request and connects to the VM from its gateway address (`10.202.0.69` in this sandbox). Home Assistant blocks forwarded requests from any proxy it hasn't been told to trust. See the [HTTP integration](https://www.home-assistant.io/integrations/http/). Adding an `http:` block to `configuration.yaml` after the first start did not fix it. Since 2026.8, per the [HTTP integration page](https://www.home-assistant.io/integrations/http/), Home Assistant imports that block once, on its first start, and keeps the settings in `.storage/http` from then on. A block added later is ignored. So write it before the first start. ## 3. Write the proxy setting, then start Home Assistant Save this as `ha-setup.sh` on your computer. It reads the gateway from the sandbox's default route and writes a minimal `configuration.yaml` before the container ever runs. ```bash # ha-setup.sh docker rm -f homeassistant >/dev/null 2>&1 sudo rm -rf ~/homeassistant && mkdir -p ~/homeassistant gw=$(ip route show default | awk '{print $3}') cat > ~/homeassistant/configuration.yaml </dev/null until curl -sf -o /dev/null localhost:8123/manifest.json; do sleep 0.5; done echo "first response after $(( ($(date +%s%N)-s)/1000000 )) ms" ``` ```bash sbx scp ha-setup.sh $id:ha-setup.sh sbx exec $id --timeout 300 "bash ha-setup.sh" ``` ```text default_config: http: use_x_forwarded_for: true trusted_proxies: - 10.202.0.69 first response after 5210 ms ``` The `docker run` line is the official one with two additions from the same page: a real time zone and `--stop-timeout 60`, so Home Assistant has time to close its database on a stop. `--restart=unless-stopped` is what brings it back after a cold boot. Keep the `default_config:` line. Without a `configuration.yaml`, Home Assistant writes one with `default_config:` in it. Because you write the file first, you have to add that line yourself. After the first start, `.storage/http` held `"trusted_proxies": ["10.202.0.69/32"]` and Home Assistant raised a repair asking you to remove the YAML block. We removed it and restarted: the hosted URL kept working, because the setting now lives in storage. From then on you change it in **Settings > System > Network > HTTP server**. ## 4. Give it a private HTTPS URL ```bash sbx host $id 8123 --title "Home Assistant" ``` ```text https://rare-even-atlas-8123.box.sandboxer.com?_token= ``` | Request | Status | | - | - | | `https://rare-even-atlas-8123.box.sandboxer.com/` (no token) | `401` from the Sandboxer proxy | | The full URL with `?_token=` | `302` to `/`, sets the `__sbx_auth` cookie | | `/` with the cookie, before onboarding | `302` to `/onboarding.html` | | `/onboarding.html` with the cookie | `200`, `Home Assistant` | Open the token URL in your browser and create the owner account. The first account becomes the owner, so do it before you send the link to anyone. [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) covers public URLs and revoking a link. We ran onboarding through the API from Node 24 so we could record each response. In a browser you fill in the same four steps: ```text onboarding/users 200 auth_code received auth/token 200 Bearer expires_in 1800 onboarding/core_config 200 onboarding/analytics 200 onboarding/integration 200 GET /api/ 200 {"message":"API running."} GET /api/config 200 2026.10.4 UTC RUNNING ``` `/api/config` reported `UTC` even though the container has `TZ=America/New_York`. The `TZ` variable sets the container's clock. Home Assistant's own time zone, used by automations, is the one you pick during onboarding (we sent an empty `core_config` step, so it stayed `UTC`). ## 5. Two locks: the Sandboxer token and the Home Assistant login Both checks apply to every request through a private URL: | Request to `/api/` | Status | | - | - | | Home Assistant bearer token, no Sandboxer cookie | `401` (the proxy stops it) | | Sandboxer cookie, no Home Assistant token | `401` (Home Assistant stops it) | | Both | `200` | A WebSocket to `/api/websocket` without the cookie was refused at the handshake. With the cookie it got `auth_required` from Home Assistant 2026.10.4. The proxy keeps the real client address. We sent a wrong password twice, once with a forged `X-Forwarded-For: 203.0.113.9` header. Both times Home Assistant logged our machine's real public IP, not the forged one. So its login-attempt ban works against the actual caller. ## 6. Try it without devices The sandbox can't see anything on your home network, so there is nothing to discover. Helpers work well for testing automations. We created a toggle over the WebSocket API (`input_boolean/create`, name "Porch light"), then called a service 20 times through the hosted URL: ```javascript const t = performance.now(); const r = await fetch(`${base}/api/services/input_boolean/toggle`, { method: "POST", headers: { Cookie: cookie, Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ entity_id: "input_boolean.porch_light" }), }); ``` ```text input_boolean/create true porch_light toggle x20 status 200 median 129 ms p90 131 ms ws: auth_required 2026.10.4 ws: auth_ok ws: result true ws: state_changed input_boolean.porch_light off -> on ``` The 129 ms is the whole round trip from our test machine to the sandbox's `eu-central` region and back, so your number depends on where you are. The WebSocket subscription saw the state change made by a REST call, which is how the frontend gets live updates. Starting the `demo` integration through the config-flow API returned `abort` with reason `not_implemented`, so we used helpers instead. ## 7. Stop, resume and reboot ```bash sbx exec $id "docker inspect -f pid={{.State.Pid}} homeassistant" sbx stop $id --wait sbx resume $id sbx exec $id "docker inspect -f pid={{.State.Pid}} homeassistant; ip route show default" ``` ```text pid=1341 ready memoryRestored=true # from the resume JSON pid=1341 default via 10.202.0.69 dev eth0 ``` Stop and resume each took about 2 seconds. Home Assistant kept running in memory, and the URL, cookie and login kept working. A resume on a machine with a different CPU type boots from disk instead (`memoryRestored: false`). To test that path we rebooted with `sudo systemctl reboot`: ```text 1.62 3.64 # /proc/uptime when exec answered 3.67 9.70 # /proc/uptime when Home Assistant answered pid=561 restarts=0 status=running default via 10.202.0.69 dev eth0 porch_light 200 on ``` The container came back on its own because of its restart policy. "Porch light" was still `on`, and the refresh token from before still worked. The gateway address was the same after the resume and the reboot. **If the URL ever starts returning 400 again**, compare `ip route show default` with the trusted proxy in **Settings > System > Network**. You can reach the UI without the proxy, so without `X-Forwarded-For`, through an SSH tunnel: ```bash sbx forward $id --remote 8123 --local 18123 # then open http://127.0.0.1:18123 on your computer ``` ```text forward localhost:18123/ 200 ``` ## What fits and what doesn't | You want to | Fits? | | - | - | | Try Home Assistant before buying hardware | Yes | | Build dashboards, test automations and templates against helpers | Yes | | Test an integration that talks to a cloud API over the internet | Yes, it has outbound internet | | Control devices on your home LAN, or use Zigbee, Z-Wave or Bluetooth | No. It isn't on your network and has no radios | | Apps (the add-on store) | No. Home Assistant's [Linux install page](https://www.home-assistant.io/installation/linux/) says the Container install has no access to apps | | Run your home 24/7 | No. Automations don't run while the sandbox is stopped | ## FAQ ### Which size should I use? We used `default`. Home Assistant used 281 MiB of RAM after onboarding, and the image is 2.35 GB. By those numbers `small` (2 vCPU, 4 GB RAM, 12 GB disk) should be enough for a trial, but we didn't run it there. ### Can I add the http block after Home Assistant has started once? Not in YAML. In 2026.10.4 a block added after the first start had no effect and every proxied request still got `400`. Use **Settings > System > Network**, reached through `sbx forward`, or start over with an empty config folder. ### Should I trust all of 10.0.0.0/8 instead of one address? No need. Trust only the gateway from `ip route show default`. That is the address the Sandboxer proxy connects from. ### Does the sandbox stop on its own? Yes. It stops after one hour by default and pauses after a period of inactivity, and no automation runs while it is stopped. Change these with `sbx extend $id --hours 12` and `sbx idle $id 0`. See [idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use). ### Do I need --privileged? Home Assistant's own command uses it, and it worked in the sandbox, which runs Docker inside its own VM. We didn't test without it. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Does a cloud sandbox pause itself when idle?](/guides/idle-auto-pause-and-wake-on-use) - [Hosting](/docs/hosting) --- # How to Run Gitea in a Sandbox and Push to It Over HTTPS Source: https://sandboxer.com/guides/run-gitea-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official gitea/gitea Docker image in a Sandboxer microVM with SQLite and --restart unless-stopped, then host port 3000 privately. Git can't use the ?_token= link, so give git the __sbx_auth cookie with http.extraHeader and use a Gitea access token as the password. Push and clone then work over HTTPS. ## What we ran Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, 12 GB disk, $0.018/hour) with Gitea 1.24.7 from the `gitea/gitea:1.24` image and git 2.53 on the laptop side. Tokens are replaced with `<token>`. Times are single runs unless the table says otherwise. | Step | Result | | - | - | | `sbx new --type small` | 3.2 s to ready | | Pull `gitea/gitea:1.24` and start it | 7.5 s to a passing `/api/healthz` | | First push (one commit) over the private URL | 0.76 s | | Mirror push of [expressjs/express](https://github.com/expressjs/express) (10.8 MB pack, 329 branches and tags) | 28.9 s | | Push a 50 MB commit of random data | 6.1 s and 6.9 s (two runs) | | Clone express back (6,183 commits) | 6.5 s | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, next push 0.73 s | ## 1. Start Gitea Docker is preinstalled in every sandbox. We followed Gitea's [Docker install guide](https://docs.gitea.com/installation/install-with-docker) and set the config with its `GITEA__section__KEY` environment variables, so no web installer runs: ```bash sbx new --name gitea --type small --no-env id=sb_o4aq9kxinn # the id that sbx new printed; its subdomain was rich-curious-trout sbx exec $id --timeout 600 "docker run -d --name gitea --restart unless-stopped \ -p 3000:3000 -v gitea-data:/data \ -e GITEA__database__DB_TYPE=sqlite3 \ -e GITEA__security__INSTALL_LOCK=true \ -e GITEA__server__ROOT_URL=https://rich-curious-trout-3000.box.sandboxer.com/ \ -e GITEA__server__DISABLE_SSH=true \ -e GITEA__service__DISABLE_REGISTRATION=true \ gitea/gitea:1.24 && until curl -fs localhost:3000/api/healthz >/dev/null; do sleep 0.5; done" sbx exec $id "docker exec gitea gitea --version" ``` ```text Gitea version 1.24.7 built with GNU Make 4.4.1, go1.24.9 : bindata, timetzdata, sqlite, sqlite_unlock_notify ``` What each setting does here: - `ROOT_URL` is the hosted URL you get in step 2: `https://<subdomain>-3000.box.sandboxer.com/`. Gitea uses it for the clone URLs it shows and returns from its API. The subdomain is in the `sbx new` output, and it stays the same across stop and resume. - `INSTALL_LOCK=true` skips the first-run web installer. Without it, the first person to open the URL configures the server. - `DISABLE_REGISTRATION=true` means only accounts you create exist. - `DISABLE_SSH=true` hides SSH clone URLs. Everything in this guide uses HTTPS. - `-v gitea-data:/data` keeps repositories, the SQLite database and config in a named volume on the sandbox disk. Gitea needs `--timeout 600` on the first `sbx exec` only because of the image pull. `sbx exec` stops a command after 30 seconds by default. ## 2. Create a user and a token, then host the port Create the admin account and an access token with Gitea's [admin CLI](https://docs.gitea.com/administration/command-line). `--raw` prints only the token, so you can write it straight to a file on your laptop: ```bash sbx exec $id "docker exec -u git gitea gitea admin user create --admin \ --username alice --password '<password>' --email alice@example.com --must-change-password=false" sbx exec $id "docker exec -u git gitea gitea admin user generate-access-token \ --username alice --token-name laptop --scopes write:repository,write:user --raw" > gitea_token ``` ```text New user 'alice' has been successfully created! ``` Now give port 3000 a URL. [Hosted URLs](/docs/hosting) are private by default: ```bash sbx host $id 3000 --title Gitea > host.json python3 -c "import json; print(json.load(open('host.json'))['url'])" | cut -d= -f2 > sbx_token ``` ```text {"ok":true,"type":"port.hosted","sandboxId":"sb_o4aq9kxinn","port":3000,"url":"https://rich-curious-trout-3000.box.sandboxer.com?_token=<token>", …} ``` You now hold two secrets, and they do different jobs: | Secret | Who checks it | What it protects | | - | - | - | | Hosted URL token (`sbx_token`) | The Sandboxer proxy, before Gitea sees anything | Whether a request reaches Gitea at all | | Gitea access token (`gitea_token`) | Gitea | Which user you are and which repos you can push to | ## 3. Why the private link doesn't work for git A browser opens the `?_token=` link once, gets a cookie and is in. Git can't do that. It appends `/info/refs?service=…` to the repository URL, and it doesn't keep cookies between commands. We tried three ways from the laptop: ```bash B=https://rich-curious-trout-3000.box.sandboxer.com GIT_TERMINAL_PROMPT=0 git ls-remote $B/alice/demo.git GIT_TERMINAL_PROMPT=0 git ls-remote "$B/alice/demo.git?_token=<token>" curl -s -w " %{http_code}\n" -H "Cookie: __sbx_auth=$(cat sbx_token)" $B/api/v1/version ``` ```text fatal: could not read Username for 'https://rich-curious-trout-3000.box.sandboxer.com': terminal prompts disabled fatal: could not read Username for 'https://rich-curious-trout-3000.box.sandboxer.com': terminal prompts disabled {"version":"1.24.7"} 200 ``` The first two got `401` from the proxy, and git read that as "Gitea wants a password". The token in the query string didn't help. What works is the `__sbx_auth` cookie, which is what the proxy sets after a browser opens the token link. Its value is the URL's token. ## 4. Configure git and push Git's [`http.<url>.extraHeader`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpextraHeader) sends a header with every request to one URL prefix, so only this server gets the cookie. The Gitea token goes in the [credential store](https://git-scm.com/docs/git-credential-store) as the password: ```bash git config --global http.$B/.extraHeader "Cookie: __sbx_auth=$(cat sbx_token)" git config --global credential.helper store printf 'protocol=https\nhost=rich-curious-trout-3000.box.sandboxer.com\nusername=alice\npassword=%s\n\n' \ "$(cat gitea_token)" | git credential approve ``` `credential.helper store` keeps the token in plain text in `~/.git-credentials`. Use your OS keychain helper instead if you have one. Either way, the cookie sits in plain text in `~/.gitconfig`, so treat that file like a password too. Create a private repository through [Gitea's API](https://docs.gitea.com/development/api-usage) (same two credentials) and push: ```bash curl -s -u "alice:$(cat gitea_token)" -H "Cookie: __sbx_auth=$(cat sbx_token)" \ -H 'Content-Type: application/json' -d '{"name":"demo","private":true}' $B/api/v1/user/repos mkdir demo && cd demo && git init -q && echo "# demo" > README.md git add . && git commit -qm "first commit" git remote add origin $B/alice/demo.git git push -u origin main ``` ```text remote: . Processing 1 references remote: Processed 1 references in total To https://rich-curious-trout-3000.box.sandboxer.com/alice/demo.git * [new branch] main -> main branch 'main' set up to track 'origin/main'. ``` That push took 0.76 seconds. In a browser, open the full `?_token=` link once, then sign in to Gitea as `alice`. ## 5. Tell the two 401s apart When a push fails, the message tells you which layer said no. These are the outputs we got: | What's wrong | git prints | | - | - | | No cookie, no stored credentials | `fatal: could not read Username for '…'` | | No cookie, correct Gitea token stored | `fatal: Authentication failed for '…'` (no `remote:` line) | | Cookie right, Gitea token wrong | `remote: Failed to authenticate user` then `fatal: Authentication failed` | | Cookie right, no credentials, private repo | `fatal: could not read Username for '…'` | A `remote:` line means the request reached Gitea, so the cookie is fine and the Gitea token is the problem. No `remote:` line with a stored token means the proxy rejected the cookie. Watch out for the second row. After an authentication failure, git tells the credential helper to forget the password it just tried. When our cookie was missing, the proxy's `401` made git erase the correct Gitea token from `~/.git-credentials` (the file was 0 bytes afterwards), and the next push failed even with the cookie fixed. Fix the cookie first, then run the `git credential approve` line from step 4 again. ## 6. A real repository: speed We mirrored a real project to see how it behaves with many refs and some history: ```bash git clone -q --bare https://github.com/expressjs/express.git express.git cd express.git && time git push -q --mirror $B/alice/express.git ``` ```text real 0m28.866s ``` The pack was 10.8 MB, but there were 329 branches and tags, and Gitea processes every pushed ref (the `Processing 329 references` line). A 50 MB commit of random data, one ref, pushed in 6.1 and 6.9 seconds. Cloning express back took 6.5 seconds and had all 6,183 commits and 306 tags. Downloads depend on your distance from the sandbox. Cloning the 96 MB `demo` repo took 58 seconds from our laptop. The same clone inside the sandbox over `localhost` took 5.4 seconds. A 50 MB download through the hosted URL took 14 to 29 seconds, and the same size from a Hetzner speed-test server in Helsinki took 21 seconds from the same laptop. Our sandbox ran in `eu-central`. So the network path set our clone speed, not the proxy. If a job clones a lot, run it in a sandbox and clone over `localhost:3000`. ## 7. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'" ``` ```text gitea Up About a minute ``` Stop took 2.2 seconds and resume 1.3 seconds. The resume reported `memoryRestored: true`, so Gitea never restarted. The hosted URL and its token were unchanged, the old cookie still worked, and the next push took 0.73 seconds. A resume boots from disk instead when no machine with the same CPU type is free, or when you resize. Then the `gitea-data` volume is there and the restart policy starts Gitea again. We didn't force that here. Restarting the Docker daemon shows the same policy at work: ```bash sbx exec $id --timeout 120 "sudo systemctl restart docker until curl -fs localhost:3000/api/healthz >/dev/null; do sleep 0.5; done docker ps --format '{{.Names}} {{.Status}}'; docker exec gitea ls /data/git/repositories/alice" ``` ```text gitea Up Less than a second demo.git express.git ``` ## 8. Public instead of private `sbx host $id 3000 --public` drops the proxy's check, and Gitea's own login is the only gate: | Request with a public URL | Result | | - | - | | `curl $B/api/v1/version`, anonymous | `200`, `{"version":"1.24.7"}` | | `git ls-remote` of the private repo, anonymous | `fatal: could not read Username` (Gitea asked for credentials) | | Same, with `alice` and the Gitea token | Lists `HEAD` | Public is simpler for CI runners and teammates: one credential, no cookie. It also exposes Gitea's login page to anyone who finds the URL. Switching back with `sbx host $id 3000 --private` minted a new token: the old cookie got `401` and the new one `200`. Update `http.<url>.extraHeader` on every machine after a switch. ## 9. Clean up ```bash sbx delete $id --yes ``` A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. Remove the `extraHeader` line and the stored credential from your laptop when you're done. ## FAQ ### Can I clone with the private `?_token=` link? No. In our test, `git ls-remote` with `?_token=` on the URL got the proxy's `401`, because git adds its own path and query after the URL. Send the token as the `__sbx_auth` cookie with `http.<url>.extraHeader` instead. ### Why did git ask for a username when my Gitea token was fine? The proxy answered `401` before the request reached Gitea, and git treats any `401` as a password prompt. Check that the cookie is set for the exact URL prefix and that you didn't switch the port public and back, which changes the token. ### Can I push over SSH instead? We didn't test it. Gitea's SSH server listens inside the container, and a sandbox's SSH endpoint is the sandbox's own `sshd`, not Gitea. This guide sets `DISABLE_SSH=true` and uses HTTPS only. ### Will my repositories survive if the sandbox auto-stops? Yes. Auto-stop is a normal stop: disk and memory are saved, and `sbx resume` brings Gitea back. The repos live in the `gitea-data` volume, and `--restart unless-stopped` covers the times a resume boots from disk. ### Do I need a bigger size than `small`? Not for this. Gitea with SQLite handled a 10.8 MB mirror and two 50 MB pushes on `small`. Moving to a smaller size isn't supported yet (you'd create a new sandbox and copy), so start small and resize up on resume if you need to. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox) - [Run MySQL or MariaDB in a sandbox](/guides/run-mysql-or-mariadb-in-a-sandbox) - [Hosting](/docs/hosting) --- # How to Run MinIO S3 Storage in a Cloud Sandbox Source: https://sandboxer.com/guides/run-minio-s3-storage-in-a-sandbox. Updated 2026-10-27. **Short answer:** MinIO's community edition is now source-only, so build it with go install inside a Sandboxer sandbox (93 seconds on default), run it as a systemd service and point the AWS CLI at it with --endpoint-url. A 1 GiB upload took 2.2 to 2.5 seconds inside the VM, and the S3 port worked on a hosted HTTPS URL. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB, 50 GB disk). The laptop side used AWS CLI 1.46 and plain `curl`. Timings are single runs or three back-to-back runs; yours will differ. | Step | Result | | - | - | | `sbx new` until ready | 3.2 s | | `go install github.com/minio/minio@latest` | 1 min 33 s, 147 MB binary | | Install AWS CLI v2 in the sandbox | 3.1 s | | `aws s3 cp` 1 GiB into MinIO, in the VM | 2.45 s, 2.17 s, 2.44 s | | `aws s3 cp` 1 GiB out of MinIO, in the VM | 1.56 s, 2.19 s, 1.99 s | | Presigned GET from the laptop over the hosted URL | 190 ms median over 20 requests, each a new TLS connection | | 64 MiB upload / download from the laptop over the hosted URL | 3.3 s / 5.5 s | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s, memory restored, all objects intact | ## Why build from source As of October 27, 2026, the [MinIO repository](https://github.com/minio/minio) README says the repository is no longer maintained and that the community edition "is now distributed as source code only", with no new pre-built binaries. Its documented install is `go install github.com/minio/minio@latest`, which needs Go 1.24 or later. A sandbox ships Go 1.27, so that path needs nothing extra. Older binaries and Docker images still exist, but the README says they won't get updates. Check the README for the current state before you rely on either. MinIO is AGPLv3; read the license page in the repo if you plan to ship it inside a product. ## 1. Build MinIO in the sandbox The build runs longer than the default 30-second `sbx exec` timeout, so pass `--timeout` (the maximum is 600; use `--detach` for anything longer): ```bash sbx new --name minio --type default --no-env id=sb_km3bx8u9ha # the id that sbx new printed sbx exec $id --timeout 600 "go version; time go install github.com/minio/minio@latest; ~/go/bin/minio --version" ``` ```text go version go1.27.2 linux/amd64 go: downloading cel.dev/expr v0.23.1 … minio version DEVELOPMENT.GOGET (commit-id=DEVELOPMENT.GOGET) Runtime: go1.27.2 linux/amd64 License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html real 1m32.817s user 3m53.265s ``` `minio --version` doesn't say which commit you got. `go version -m` does. On our run, `@latest` resolved to a commit from February 12, 2026: ```bash sbx exec $id "go version -m ~/go/bin/minio | sed -n 2,3p" ``` ```text path github.com/minio/minio mod github.com/minio/minio v0.0.0-20260212201848-7aac2a2c5b7c h1:… ``` Pin that pseudo-version instead of `@latest` (`go install github.com/minio/minio@v0.0.0-20260212201848-7aac2a2c5b7c`) if you want the same build every time. ## 2. Run it as a systemd service MinIO's default login is `minioadmin:minioadmin`. Don't keep it, because step 4 puts the S3 port on the internet. Save this unit as `minio.service` on your laptop: ```ini [Unit] Description=MinIO object storage After=network-online.target Wants=network-online.target [Service] User=user EnvironmentFile=/etc/default/minio ExecStart=/usr/local/bin/minio server /home/user/minio-data --address :9000 --console-address :9001 Restart=always LimitNOFILE=65536 [Install] WantedBy=multi-user.target ``` Copy it in, generate a random root password into a root-only file, and start the service: ```bash sbx scp ./minio.service $id:/home/user/ sbx exec $id --timeout 120 ' sudo install -m 755 ~/go/bin/minio /usr/local/bin/minio mkdir -p ~/minio-data printf "MINIO_ROOT_USER=admin\nMINIO_ROOT_PASSWORD=%s\n" $(openssl rand -hex 16) | sudo tee /etc/default/minio >/dev/null sudo chmod 600 /etc/default/minio sudo mv minio.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now minio sleep 3; systemctl is-active minio curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/minio/health/live' ``` ```text Created symlink /etc/systemd/system/multi-user.target.wants/minio.service → /etc/systemd/system/minio.service. active 200 ``` Port 9000 is the S3 API. Port 9001 is the web console. `--console-address` fixes the console port; without it, MinIO picks a random one. ## 3. Use the AWS CLI in the sandbox Install AWS CLI v2 with [AWS's own installer](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) (`unzip` is already there), then make a profile from the MinIO credentials. `AWS_ENDPOINT_URL` sends every command to MinIO instead of AWS: ```bash sbx exec $id --timeout 300 ' cd /tmp && curl -sSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o awscliv2.zip \ && unzip -q awscliv2.zip && sudo ./aws/install >/dev/null && aws --version . <(sudo cat /etc/default/minio) aws configure set aws_access_key_id "$MINIO_ROOT_USER" --profile minio aws configure set aws_secret_access_key "$MINIO_ROOT_PASSWORD" --profile minio aws configure set region us-east-1 --profile minio' ``` ```text aws-cli/2.38.6 Python/3.14.8 Linux/6.18.56-sandboxer exe/x86_64.ubuntu.24 ``` Now create a bucket and time 1 GiB of random data in and out. Save this as `s3test.sh`: ```bash export AWS_PROFILE=minio AWS_ENDPOINT_URL=http://127.0.0.1:9000 TIMEFORMAT='%R s' aws s3 mb s3://demo head -c 1G /dev/urandom > /tmp/big.bin for i in 1 2 3; do echo -n "upload 1 GiB: "; { time aws s3 cp --no-progress /tmp/big.bin s3://demo/big-$i.bin >/dev/null; } 2>&1; done for i in 1 2 3; do echo -n "download 1 GiB: "; { time aws s3 cp --no-progress s3://demo/big-$i.bin /tmp/back.bin >/dev/null; } 2>&1; done cmp /tmp/big.bin /tmp/back.bin && echo "checksum match" aws s3api head-object --bucket demo --key big-1.bin --query '{ETag:ETag,Size:ContentLength}' ``` ```bash sbx scp ./s3test.sh $id:/home/user/ sbx exec $id --timeout 600 "bash s3test.sh" ``` ```text make_bucket: demo upload 1 GiB: 2.453 s upload 1 GiB: 2.171 s upload 1 GiB: 2.435 s download 1 GiB: 1.558 s download 1 GiB: 2.191 s download 1 GiB: 1.988 s checksum match { "ETag": "\"dc6b68ab07b203cdca0a0afada7bc801-128\"", "Size": 1073741824 } ``` That is roughly 440 to 495 MB/s up and 490 to 690 MB/s down, with client and server on the same 4 vCPU VM. The `-128` on the ETag means the CLI split the file into 128 multipart chunks of 8 MiB, its default. That is useful to check if your code compares ETags with MD5 sums: multipart ETags aren't MD5s of the file. ## 4. Reach the S3 API from your laptop There are two ways. Pick by who needs access. | You want | Do this | Who can reach the bucket | | - | - | - | | The AWS CLI or an SDK on your own machine only | `ssh -L` tunnel to port 9000, keep it unhosted | Only you, through SSH with your key | | Any S3 client anywhere, or presigned links to share | `sbx host $id 9000 --public` | Anyone with valid S3 credentials or a presigned URL; anonymous requests get `403` | **A private hosted URL doesn't work for S3 clients.** We hosted 9000 privately first. The AWS CLI can't attach the URL's `_token`, so it got the proxy's `401`: ```bash aws --endpoint-url https://prompt-copper-marsh-9000.box.sandboxer.com s3 ls ``` ```text An error occurred (401) when calling the ListBuckets operation: Unauthorized ``` **Option A: SSH tunnel.** Connect once with `sbx ssh` so the CLI authorizes its key, then forward a local port. `sbx ssh-config > ~/.ssh/sbx.conf` saves the `Host *.sbx` stanza that `-F` reads; the [MongoDB guide](/guides/run-mongodb-in-a-sandbox) walks through it. ```bash sbx ssh $id true ssh -F ~/.ssh/sbx.conf -f -N -o ExitOnForwardFailure=yes -L 9100:127.0.0.1:9000 $id.sbx aws --endpoint-url http://127.0.0.1:9100 s3 ls s3://demo/ --recursive ``` ```text 2026-10-27 09:57:58 1073741824 big-1.bin 2026-10-27 09:58:01 1073741824 big-2.bin 2026-10-27 09:58:03 1073741824 big-3.bin … ``` **Option B: a public hosted URL.** Hosted URLs serve HTTPS, and MinIO's own SigV4 check still guards every request, so a public S3 port is as safe as your MinIO credentials. That is why step 2 replaced the default password. ```bash sbx host $id 9000 --public B=https://prompt-copper-marsh-9000.box.sandboxer.com curl -s -o /dev/null -w '%{http_code}\n' $B/minio/health/live # health check curl -s $B/demo/ | head -c 120; echo # anonymous listing ``` ```text 200 <?xml version="1.0" encoding="UTF-8"?> <Error><Code>AccessDenied</Code><Message>Access Denied.</Message><BucketName>demo</BucketName> ``` With the credentials in `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` on the laptop, the normal CLI works through the URL. Signatures survive the proxy: ```bash echo "hello from my laptop" > note.txt aws --endpoint-url $B s3 cp note.txt s3://demo/notes/note.txt url=$(aws --endpoint-url $B s3 presign s3://demo/notes/note.txt --expires-in 300) curl -s "$url" curl -s -o /dev/null -w '%{http_code}\n' $B/demo/notes/note.txt ``` ```text upload: ./note.txt to s3://demo/notes/note.txt hello from my laptop 403 ``` The presigned link works for anyone for 5 minutes. The same object without a signature gets `403`. To size the network cost, we fetched the presigned URL 20 times with `curl` (`-w '%{time_total}'`): median 190 ms, min 189 ms, max 234 ms. Each request opened a new TLS connection, so an SDK that reuses connections will do better. A 64 MiB file took 3.3 s up and 5.5 s down from our laptop. That is the laptop's link, not MinIO, so put bulk jobs in the sandbox next to the data. ## 5. The web console on a private URL The console is a browser app, so a private hosted URL fits it: ```bash sbx host $id 9001 --title "MinIO console" ``` It prints `https://prompt-copper-marsh-9001.box.sandboxer.com?_token=…`. Treat it like a password. Without the token the proxy answered `401`. With it, `curl -L` with a cookie jar got `200` and a page titled `MinIO Console`. Log in with `admin` and the password in `/etc/default/minio`. Read it with `sudo cat` in an `sbx ssh` session, not in a shared log. We checked the HTTP status and page title only, not what the console can do. ## 6. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id aws --endpoint-url $B s3 ls s3://demo/ --recursive --summarize | tail -2 ``` ```text Total Objects: 5 Total Size: 3288334357 ``` The stop took 2.2 s and the resume 1.4 s, with `memoryRestored: true`. `systemctl show minio -p ActiveEnterTimestamp` still showed the original start time, so MinIO never restarted. Both hosted URLs answered again with the same token. In this run, the laptop's `ssh -L` tunnel also kept working after the resume. Don't count on it: if yours has dropped, run the same `ssh -L` line again. A resume boots from disk instead when no machine with the same CPU type is free, or when you move to a bigger size. Then processes are gone, but the objects in `~/minio-data` are on the disk, and the `enable`d unit starts MinIO at boot. We didn't force a disk boot. We did kill the process to check `Restart=always`: ```bash sbx exec $id "systemctl is-enabled minio; sudo pkill -9 -x minio; sleep 7; systemctl show minio -p NRestarts -p ActiveState" ``` ```text enabled NRestarts=1 ActiveState=active ``` Moving to a smaller size isn't supported yet. Create a new sandbox and copy the bucket across with `aws s3 sync` between the two endpoints. ## 7. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Can I use the official MinIO Docker image instead? The [minio/minio repository](https://github.com/minio/minio) says the community edition is now source-only and older pre-built releases won't be updated. Docker is preinstalled, so an existing image will run, but we built from source as the README recommends and didn't test an image here. ### Do AWS SDKs need path-style addressing for MinIO? Use path-style. Virtual-host style puts the bucket in the hostname (`demo.prompt-copper-marsh-9000…`), a name that `sbx host` never gives you, so we didn't try it. The AWS CLI used path-style requests against our custom endpoint without any setting. In an SDK, set the path-style option, for example `forcePathStyle: true` in the AWS SDK for JavaScript v3. We tested only the CLI. ### Why does `aws s3 ls` get 401 on a private hosted URL? The `401` comes from Sandboxer's proxy, before MinIO sees the request. Private URLs need the `_token` query parameter or the cookie it sets, and S3 clients send neither. Use an SSH tunnel, or host the port with `--public` and rely on MinIO's credentials. ### Where are the objects stored, and how much fits? On the sandbox disk under `/home/user/minio-data`. A `default` sandbox has a 50 GB disk; `df -h` showed 43 GB free after the build and three 1 GiB objects. [Machine sizes](/docs/machines) lists the disk for each type. ### Can a sandbox upload to real Amazon S3 instead? Yes. Outbound HTTPS is open, so drop `--endpoint-url` and use AWS credentials. Pass them as described in [passing secrets to a sandbox](/guides/pass-secrets-to-a-sandbox-safely) rather than writing them into scripts. ## Related - [Run MongoDB in a sandbox](/guides/run-mongodb-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox) - [Hosting](/docs/hosting) --- # How to Run Meilisearch in a Cloud Sandbox Source: https://sandboxer.com/guides/run-meilisearch-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official getmeili/meilisearch Docker image in a Sandboxer microVM with a master key, a named volume and --restart unless-stopped, then host port 7700. We indexed 31,944 movies in 5.8 s; typo-tolerant searches took 0 to 2 ms in Meilisearch and 191 ms round trip from a laptop. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB): Meilisearch 1.56.0 from the official `getmeili/meilisearch:v1.56.0` image, with the `movies.json` dataset from Meilisearch's own [quick start](https://www.meilisearch.com/docs/resources/self_hosting/getting_started/quick_start). Timings are single runs, or 20 runs where we give a median. Yours will differ. | Step | Result | | - | - | | `sbx new` until ready | 3.2 s | | Pull the image, start it, wait for `/health` | 5.4 s | | Index 31,944 documents (16 MB of JSON) | 5.8 s (task `duration`) | | Make `genres` filterable, `release_date` sortable | 0.36 s | | Typo search, filter + sort, facet count | `processingTimeMs` 1, 2 and 0 | | 20 searches from a laptop over the hosted URL | 191 ms median round trip, 0 ms in Meilisearch | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.4 s, memory restored, first search answered 0.2 s after resume | Meilisearch speaks plain HTTP, so unlike a database you reach through an SSH tunnel, you can put it straight on a [hosted HTTPS URL](/guides/give-a-sandbox-port-an-https-url). That raises the question this guide spends most of its time on: Sandboxer's private URL token and Meilisearch's own API keys are two separate locks. Section 4 shows what each one does. ## 1. Start Meilisearch with a master key Docker is preinstalled in every sandbox. Meilisearch's [install docs](https://www.meilisearch.com/docs/resources/self_hosting/getting_started/install_locally) give the image and the `/meili_data` volume. Make a master key on your laptop, then start the container. Image pulls can take longer than the default 30-second `sbx exec` timeout, so pass `--timeout`: ```bash sbx new --name meili --type default --no-env id=sb_61negiqzw8 # the id that sbx new printed MEILI_MASTER_KEY=$(openssl rand -hex 16) sbx exec $id --timeout 600 "docker run -d --name meili --restart unless-stopped -p 7700:7700 \ -e MEILI_ENV=production -e MEILI_MASTER_KEY=$MEILI_MASTER_KEY \ -v meili_data:/meili_data getmeili/meilisearch:v1.56.0 >/dev/null \ && until curl -sf localhost:7700/health; do sleep 0.3; done; echo; \ curl -s localhost:7700/version -H 'Authorization: Bearer $MEILI_MASTER_KEY'" ``` ```text {"status":"available"} {"commitSha":"47567abc0e7e86b9f561ef2dbe9fd9764c6955d8","commitDate":"unknown","pkgVersion":"1.56.0"} ``` Three settings matter: - `MEILI_ENV=production` makes the master key mandatory and turns off the built-in search preview page. Over the hosted URL, `/` then answers `{"status":"Meilisearch is running"}` instead of a UI. Use `development` only if you want that preview page and keep the URL private. - `-v meili_data:/meili_data` keeps the index in a named Docker volume on the sandbox disk. After indexing, it held 180.6 MB. - `--restart unless-stopped` makes Docker start Meilisearch again if a resume boots the sandbox from disk instead of restoring memory. Keep `$MEILI_MASTER_KEY` on your laptop. Anyone with it can read, change and delete every index. ## 2. Index a dataset and wait for the task Meilisearch indexes asynchronously: adding documents returns a task id at once, and the work happens in the background. Download the quick start dataset in the sandbox, send it, then poll the task until it finishes: ```bash sbx exec $id --timeout 300 "curl -sSL -o movies.json https://www.meilisearch.com/docs/assets/datasets/movies.json \ && jq length movies.json" sbx exec $id --timeout 300 "curl -s -X POST 'localhost:7700/indexes/movies/documents?primaryKey=id' \ -H 'Content-Type: application/json' -H 'Authorization: Bearer $MEILI_MASTER_KEY' --data-binary @movies.json; echo; \ until curl -s localhost:7700/tasks/0 -H 'Authorization: Bearer $MEILI_MASTER_KEY' \ | jq -e '.status==\"succeeded\" or .status==\"failed\"' >/dev/null; do sleep 0.5; done; \ curl -s localhost:7700/tasks/0 -H 'Authorization: Bearer $MEILI_MASTER_KEY' | jq -c '{status,details,duration}'" ``` ```text 31944 {"taskUid":0,"indexUid":"movies","status":"enqueued","type":"documentAdditionOrUpdate","enqueuedAt":"2026-10-27T09:58:21.318436291Z"} {"status":"succeeded","details":{"receivedDocuments":31944,"indexedDocuments":31944},"duration":"PT5.834307239S"} ``` Checking the task matters. If a document is malformed, the `POST` still returns `enqueued`, and only the task shows `failed` with the reason. Filtering and sorting need the fields declared first. This is another task. Changing settings on a full index rebuilds part of it, so declare them before a large import when you can: ```bash sbx exec $id "curl -s -X PATCH localhost:7700/indexes/movies/settings \ -H 'Authorization: Bearer $MEILI_MASTER_KEY' -H 'Content-Type: application/json' \ --data '{\"filterableAttributes\":[\"genres\",\"release_date\"],\"sortableAttributes\":[\"release_date\"]}'" ``` Task 1 finished with `"duration":"PT0.361564575S"`. ## 3. Search: typos, filters and facets All three queries below ran inside the sandbox against `localhost:7700`, so `processingTimeMs` is Meilisearch's own time with no network. A misspelled query still finds matches, because [typo tolerance](https://www.meilisearch.com/docs/capabilities/full_text_search/relevancy/typo_tolerance_settings) is on by default: ```json {"q": "batmn", "limit": 3, "attributesToRetrieve": ["title", "release_date"]} ``` ```text {"hits":[{"title":"The Dark Knight","release_date":1216166400},{"title":"Batman","release_date":614563200},{"title":"Batman Begins","release_date":1118361600}],"estimatedTotalHits":62,"processingTimeMs":1} ``` A filter plus a sort, newest first: ```json {"q": "space", "filter": "genres = Animation", "sort": ["release_date:desc"], "limit": 3, "attributesToRetrieve": ["title", "genres", "release_date"]} ``` ```text {"hits":[{"title":"Rocko's Modern Life: Static Cling",…},{"title":"The Lego Movie 2: The Second Part",…},{"title":"The Jetsons & WWE: Robo-WrestleMania!",…}],"estimatedTotalHits":70,"processingTimeMs":2} ``` Facet counts over the whole index, with no hits returned (`"q": "", "facets": ["genres"], "limit": 0`), top five shown: ```text {"total":31944,"top":{"Drama":12407,"Comedy":9331,"Thriller":6503,"Action":5403,"Horror":4591},"processingTimeMs":0} ``` Each query was sent as `curl -s -X POST localhost:7700/indexes/movies/search -H "Authorization: Bearer …" -H 'Content-Type: application/json' --data '<json>'` and trimmed with `jq`. ## 4. Put it on an HTTPS URL: public with keys, or private Meilisearch creates default API keys from the master key. List them by name and scope, not by value: ```bash sbx exec $id "curl -s localhost:7700/keys -H 'Authorization: Bearer $MEILI_MASTER_KEY'" \ | jq -r '.results[] | "\(.name) | \(.actions|join(","))"' ``` ```text Default Search API Key | search Default Admin API Key | * Default Read-Only Admin API Key | *.get,keys.get Default Chat API Key | chatCompletions,search ``` Now you have two ways to publish port 7700. We ran both. ### Public URL, gated by Meilisearch keys ```bash sbx host $id 7700 --public # printed https://golden-airy-daisy-7700.box.sandboxer.com ``` | Request to the public URL | Status | Who answered | | - | - | - | | `GET /health`, no key | `200 {"status":"available"}` | Meilisearch | | `POST /indexes/movies/search`, no key | `401 missing_authorization_header` | Meilisearch | | Same, with the Default Search API Key | `200`, hits returned | Meilisearch | | `POST /indexes/movies/documents` with the search key | `403 invalid_api_key` | Meilisearch | | `OPTIONS` preflight from `Origin: https://example.com` | `200`, `access-control-allow-origin: *` | Meilisearch | This is the setup for a search box in a web page. The browser calls the URL directly with the search key, and the search key can't write. Meilisearch's [security docs](https://www.meilisearch.com/docs/resources/self_hosting/security/basic_security) describe the same split. Only the master key and the admin key need to stay secret. From the laptop, 20 searches for `star wars` over this URL took 190 ms minimum, 191 ms median and 227 ms maximum. Meilisearch reported 0 to 1 ms of that. Nearly all the time is the network between our laptop and the sandbox's region (`eu-central`), so place the sandbox near your users. ### Private URL, gated by Sandboxer first ```bash sbx host $id 7700 --private # prints the URL with ?_token=<token>; keep it secret ``` Behind a private URL, a request needs the Sandboxer token or its cookie and, separately, a Meilisearch key: | Request to the private URL | Status | Who answered | | - | - | - | | Search key, no Sandboxer token | `401` "Access token required" page | Sandboxer proxy, Meilisearch never saw it | | `POST /indexes/movies/search?_token=<token>` with the search key | `200`, hits | Meilisearch | | `GET /health?_token=<token>` | `302` to `/health`, sets `__sbx_auth` cookie | Sandboxer proxy | | `__sbx_auth` cookie, no Meilisearch key | `401 missing_authorization_header` | Meilisearch | | `__sbx_auth` cookie plus the search key | `200`, hits | Meilisearch | Two things to know from this table. A `GET` carrying `?_token=` gets a redirect that trades the token for a cookie, so a script must keep cookies (`curl -c jar -b jar -L`) or send the cookie itself. A `POST` with `?_token=` went straight through. And the proxy passes your `Authorization` header to Meilisearch untouched, so the two layers don't interfere. ### Which to pick | You want | Use | | - | - | | A search box in a web page or app | `--public`, `MEILI_ENV=production`, ship only the search key (or a narrower key you create, see Meilisearch's [security overview](https://www.meilisearch.com/docs/capabilities/security/overview)) | | An index only you and your agents use | `--private`, plus the master key or admin key | | The search preview page in a browser | `MEILI_ENV=development` and `--private`, never `--public` | | No HTTP exposure at all | Don't host the port; call `localhost:7700` with `sbx exec`, or tunnel with SSH as in [the MongoDB recipe](/guides/run-mongodb-in-a-sandbox) | A private URL shuts out anyone without the link, even if a Meilisearch key leaks. A public URL is simpler for browsers. Cross-site `fetch` calls don't carry the `__sbx_auth` cookie by default, so a page on another site can't easily use a private URL. ## 5. Stop and resume ```bash sbx stop $id --wait # 2.2 s sbx resume $id # 1.4 s, memoryRestored: true ``` While the sandbox was stopped, the hosted URL returned `404`. After `sbx resume`, the private URL's token and cookie still worked: the first search answered 0.2 s after the CLI returned, and the index still held 31,944 documents. Memory was restored, so the same Meilisearch process kept running. If a resume lands on a machine with a different CPU type, the sandbox boots from disk with `memoryRestored: false`. Your index survives in the `meili_data` volume, and `--restart unless-stopped` starts the container again. We didn't force that case in this run. The [MySQL recipe](/guides/run-mysql-or-mariadb-in-a-sandbox) shows the restart policy bringing a container back after a Docker restart. A `default` sandbox costs $0.036/hour while it runs and nothing while it's stopped. ## FAQ ### Why does my search return 401 when the URL is public? Meilisearch answered that, not Sandboxer. With a master key set, every route we tried except `/` and `/health` needed `Authorization: Bearer <key>`. Use the Default Search API Key for searches. ### Where did the search preview page go? `MEILI_ENV=production` turns it off, so `/` returns `{"status":"Meilisearch is running"}`. Start the container with `MEILI_ENV=development` to get it back, and host the port privately. ### Why does my import say "enqueued" but no documents show up? Indexing is asynchronous. Poll `GET /tasks/<taskUid>` until `status` is `succeeded` or `failed`. A failed task has an `error` field with the reason. ### Is a CPU sandbox enough for Meilisearch? For this dataset, yes. 31,944 documents indexed in 5.8 s on 4 vCPU, and searches took 0 to 2 ms. Indexing time and disk use grow with your data. Measure your own set, and watch the volume size: 16 MB of JSON became 180.6 MB on disk. ### Can I use the official SDKs? Yes, point them at the hosted URL and a key. We tested with `curl` only. Over a private URL, the client also has to send the `__sbx_auth` cookie, so a public URL with a search key is simpler for SDKs. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How to Run the Qdrant Vector Database in a Cloud Sandbox Source: https://sandboxer.com/guides/run-qdrant-vector-database-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official qdrant/qdrant Docker image in a Sandboxer microVM with an API key, bound to 127.0.0.1, then publish it with sbx host --localhost. On a small sandbox, 20,000 vectors of 384 dimensions loaded in 2 seconds and a filtered top-10 search took 4 ms inside the VM. ## What we ran Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB): Qdrant 1.20.0 from the `qdrant/qdrant:v1.20.0` image and the Python `qdrant-client` 1.20.0. The vectors are random numbers from NumPy, not embeddings from a model, so the timings measure Qdrant, not a model. Timings are single runs or medians of 20 to 30 queries. Yours will differ. | Step | Result | | - | - | | `sbx new --type small` until ready | 3.2 s | | Pull the image, start Qdrant, wait for `/readyz` | 5.0 s | | Upsert 20,000 points (384 dims, 2 payload fields) in batches of 1,000 | 2.0 s | | Collection `green` after the last upsert | 2.5 s, 14,000 of 20,000 vectors in the HNSW index | | Top-10 search, no filter, inside the VM | 2.8 ms median, 6 of the 10 exact neighbors | | Top-10 search with a `category` + `price` filter, inside the VM | 4.1 ms median, 10 of 10 exact neighbors | | Same kind of search from a laptop through the private hosted URL | 128 ms median round trip | | Snapshot the collection, then download it through the hosted URL | 0.4 s to create, 74 MB, 28.7 s to download | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, memory restored, Qdrant never restarted | | Resume on a bigger size (boots from disk) | Docker restarted Qdrant; both collections intact | This page is about reaching Qdrant over HTTPS from outside the VM. If you'd rather keep a database behind an SSH tunnel, see [Postgres with pgvector](/guides/run-postgres-with-pgvector-in-a-sandbox), which uses `ssh -L`. ## 1. Start Qdrant with an API key and a restart policy Docker is preinstalled in every sandbox. Qdrant's own [install docs](https://qdrant.tech/documentation/guides/installation/) use the `qdrant/qdrant` image; we pinned a version. The [security guide](https://qdrant.tech/documentation/guides/security/) sets the API key through the `QDRANT__SERVICE__API_KEY` environment variable: ```bash sbx new --name qdrant --type small --no-env id=sb_3hog0mh29o # the id that sbx new printed sbx exec $id --timeout 600 'openssl rand -hex 24 > ~/qdrant.key && chmod 600 ~/qdrant.key && \ docker run -d --name qdrant --restart unless-stopped \ -p 127.0.0.1:6333:6333 -v qdrant_storage:/qdrant/storage \ -e QDRANT__SERVICE__API_KEY=$(cat ~/qdrant.key) qdrant/qdrant:v1.20.0 && \ until curl -sf http://127.0.0.1:6333/readyz >/dev/null; do sleep 0.3; done' sbx exec $id 'curl -s http://127.0.0.1:6333/readyz; echo curl -s -o /dev/null -w "no key: %{http_code}\n" http://127.0.0.1:6333/collections curl -s -H "api-key: $(cat ~/qdrant.key)" http://127.0.0.1:6333/' ``` ```text all shards are ready no key: 401 {"title":"qdrant - vector search engine","version":"1.20.0","commit":"222b2e44ab1c3f17e8246396ead6558e45041df7"} ``` What each part does: - `-p 127.0.0.1:6333:6333` keeps the REST port on the VM's loopback. We publish it in step 3 with `--localhost`, so the only way in from outside is the hosted URL. - `QDRANT__SERVICE__API_KEY` makes Qdrant refuse requests without the key (`401` above). The hosted URL already has its own token; the key is a second lock, and it also protects Qdrant from other processes in the VM. - `-v qdrant_storage:/qdrant/storage` puts the data in a Docker volume on the sandbox disk. - `--restart unless-stopped` brings Qdrant back when a resume boots from disk (step 5). The key file stays in the sandbox. Copy it to your laptop with `sbx scp $id:/home/user/qdrant.key .` when you need it there. ## 2. Upsert 20,000 points and search with a filter Install the client in a virtual environment inside the sandbox. `uv` is preinstalled: ```bash sbx exec $id --timeout 300 'bash -lc "uv venv -q .venv && uv pip install -q --python .venv/bin/python qdrant-client==1.20.0 numpy"' ``` Save this as `load.py`. It creates a collection with cosine distance, adds a [payload index](https://qdrant.tech/documentation/concepts/indexing/) on `category`, upserts 20,000 points with two payload fields, waits for the collection to turn `green`, then searches with and without a [filter](https://qdrant.tech/documentation/concepts/filtering/): ```python import pathlib, statistics, time import numpy as np from qdrant_client import QdrantClient, models key = pathlib.Path.home().joinpath("qdrant.key").read_text().strip() client = QdrantClient(url="http://127.0.0.1:6333", api_key=key, https=False) print("server", client.info().version) N, DIM = 20_000, 384 rng = np.random.default_rng(7) vectors = rng.random((N, DIM), dtype=np.float32) categories = ["books", "music", "games", "tools", "garden"] if client.collection_exists("products"): client.delete_collection("products") client.create_collection( "products", vectors_config=models.VectorParams(size=DIM, distance=models.Distance.COSINE), ) client.create_payload_index("products", "category", models.PayloadSchemaType.KEYWORD) t0 = time.perf_counter() for start in range(0, N, 1000): client.upsert( "products", points=models.Batch( ids=list(range(start, start + 1000)), vectors=vectors[start:start + 1000].tolist(), payloads=[{"category": categories[i % 5], "price": int(i % 100)} for i in range(start, start + 1000)], ), wait=True, ) print(f"upsert {N} points: {time.perf_counter() - t0:.1f} s") t0 = time.perf_counter() while True: info = client.get_collection("products") if info.status == models.CollectionStatus.GREEN: break time.sleep(0.2) print(f"index ready after {time.perf_counter() - t0:.1f} s: status={info.status.value} points={info.points_count} indexed={info.indexed_vectors_count}") flt = models.Filter(must=[ models.FieldCondition(key="category", match=models.MatchValue(value="books")), models.FieldCondition(key="price", range=models.Range(lt=20)), ]) q = vectors[42].tolist() def timed(**kw): times = [] for _ in range(30): t = time.perf_counter() res = client.query_points("products", query=q, limit=10, **kw).points times.append((time.perf_counter() - t) * 1000) return res, statistics.median(times) hits, ms = timed() print(f"top-10, no filter: median {ms:.1f} ms; first hit id={hits[0].id} score={hits[0].score:.4f}") hits, ms = timed(query_filter=flt) print(f"top-10, category=books and price<20: median {ms:.1f} ms") for h in hits[:3]: print(" ", h.id, round(h.score, 4), h.payload) exact, _ = timed(query_filter=flt, search_params=models.SearchParams(exact=True)) print("overlap with exact search:", len({h.id for h in hits} & {h.id for h in exact}), "of 10") ``` ```bash sbx scp ./load.py $id:/home/user/ sbx exec $id --timeout 300 '.venv/bin/python -u load.py' ``` ```text server 1.20.0 upsert 20000 points: 2.0 s index ready after 2.5 s: status=green points=20000 indexed=14000 top-10, no filter: median 2.8 ms; first hit id=17341 score=0.7925 top-10, category=books and price<20: median 4.1 ms 7705 0.7838 {'category': 'books', 'price': 5} 9805 0.7776 {'category': 'books', 'price': 5} 2005 0.7774 {'category': 'books', 'price': 5} overlap with exact search: 10 of 10 ``` The client also prints `UserWarning: Api key is used with an insecure connection`. Inside the VM on loopback that is expected; from outside you use HTTPS (step 3). ### Two things in that output that surprise people **`green` does not mean every vector is indexed.** Our first version of this script waited for `indexed_vectors_count` to reach 20,000. It never did: the collection was `green` with 14,000 indexed, and the loop spun until `sbx exec` gave up. Qdrant builds an HNSW index only for segments bigger than the optimizer's `indexing_threshold` (10,000 KB by default, shown in the collection info). Smaller segments are searched by a plain scan. Wait for `status == green`, not for the indexed count to match the point count. See [Qdrant's optimizer docs](https://qdrant.tech/documentation/concepts/optimizer/). **The unfiltered search missed the query's own point.** The query is point 42's vector, so point 42 should come first with a score of 1.0. With the default search settings it did not appear at all. Save this as `recall.py` and compare with exact search and a larger `hnsw_ef` ([search parameters](https://qdrant.tech/documentation/concepts/search/)): ```python import pathlib from qdrant_client import QdrantClient, models c = QdrantClient(url="http://127.0.0.1:6333", api_key=pathlib.Path.home().joinpath("qdrant.key").read_text().strip(), https=False) q = c.retrieve("products", ids=[42], with_vectors=True)[0].vector for label, params in [("hnsw ef=default", None), ("hnsw ef=256", models.SearchParams(hnsw_ef=256)), ("exact", models.SearchParams(exact=True))]: ids = [p.id for p in c.query_points("products", query=q, limit=10, search_params=params).points] print(f"{label:16} first={ids[0]} contains 42: {42 in ids}") if label == "exact": exact = set(ids) else: globals()[label] = set(ids) print("default overlap with exact:", len(globals()["hnsw ef=default"] & exact), "of 10; ef=256:", len(globals()["hnsw ef=256"] & exact), "of 10") ``` ```text hnsw ef=default first=17341 contains 42: False hnsw ef=256 first=42 contains 42: True exact first=42 contains 42: True default overlap with exact: 6 of 10; ef=256: 10 of 10 ``` Uniform random vectors are a hard case for approximate search, because every point is about the same distance from every other point. Real embeddings cluster, so recall is usually better. Run this file against your own data before you tune anything. The filtered search above was exact (10 of 10): the filter matched only about 800 points, and Qdrant's query planner can answer a filter that selective from the payload index without the HNSW graph, as its [indexing docs](https://qdrant.tech/documentation/concepts/indexing/) describe. ## 3. Publish Qdrant on a private HTTPS URL Qdrant listens on `127.0.0.1` in the VM, so host the port with `--localhost`. The sandbox relays the connection to loopback. A new hosted port is private by default ([hosting docs](/docs/hosting)): ```bash sbx host $id 6333 --localhost --title Qdrant ``` ```text https://lively-global-kestrel-6333.box.sandboxer.com?_token=<token> ``` Now there are two locks: the hosted URL's token (checked by Sandboxer's proxy) and Qdrant's API key (checked by Qdrant). This is what each combination got: | Request | Status | | - | - | | No token | `401` from the proxy, never reaches Qdrant | | `GET ?_token=<token>` | `302`, sets the `__sbx_auth` cookie | | Cookie, no `api-key` header | `401` from Qdrant: `Must provide an API key or an Authorization bearer token` | | Cookie and `api-key` | `200` | | `GET /dashboard/` with the cookie | `200`, Qdrant's web UI (it asks for the API key) | In a browser, open the full token URL once, then go to `/dashboard/` on the same host. The cookie lasts 30 days. ### Query from your laptop with qdrant-client A script has to trade the token for the cookie first, then send the cookie on every request. `qdrant-client` takes extra headers through `headers=`. Save as `remote.py` on your laptop: ```python # QDRANT_URL_WITH_TOKEN=<private hosted URL> QDRANT_API_KEY=<key> python remote.py import os, statistics, time from urllib.parse import urlsplit import httpx from qdrant_client import QdrantClient, models link = os.environ["QDRANT_URL_WITH_TOKEN"] r = httpx.get(link, follow_redirects=False) # trade the token for the cookie cookie = r.headers["set-cookie"].split(";")[0] base = "https://" + urlsplit(link).hostname client = QdrantClient(url=base, port=443, api_key=os.environ["QDRANT_API_KEY"], headers={"Cookie": cookie}, timeout=10) print("server", client.info().version) print("points", client.count("products").count) q = client.retrieve("products", ids=[42], with_vectors=True)[0].vector flt = models.Filter(must=[models.FieldCondition(key="category", match=models.MatchValue(value="books"))]) times = [] for _ in range(20): t = time.perf_counter() hits = client.query_points("products", query=q, query_filter=flt, limit=5).points times.append((time.perf_counter() - t) * 1000) print([(h.id, round(h.score, 4)) for h in hits]) print(f"median {statistics.median(times):.0f} ms, min {min(times):.0f} ms over {len(times)} queries") ``` ```text server 1.20.0 points 20000 [(7705, 0.7838), (18855, 0.7832), (3375, 0.7825), (9295, 0.7812), (14485, 0.7809)] median 128 ms, min 128 ms over 20 queries ``` Keep `port=443`. Without it, `qdrant-client` adds its default port 6333 to the URL, and the call fails with `[Errno 111] Connection refused`. We tried it. The search itself takes a few milliseconds. Almost all of the 128 ms is the network path from our laptop to the sandbox and back, which depends on where you are. For a loop of queries, run the client in the sandbox, next to Qdrant. ## 4. Back up a collection with a snapshot Qdrant's [snapshot API](https://qdrant.tech/documentation/concepts/snapshots/) writes a collection to one file. Create it and download it through the same hosted URL (`$C` is the `__sbx_auth=…` cookie, `$K` the API key): ```bash B=https://lively-global-kestrel-6333.box.sandboxer.com curl -s -X POST -H "Cookie: $C" -H "api-key: $K" "$B/collections/products/snapshots?wait=true" curl -s -H "Cookie: $C" -H "api-key: $K" -o products.snapshot \ "$B/collections/products/snapshots/products-4813605789839310-2026-10-27-10-06-18-0.snapshot" ``` ```text {"result":{"name":"products-4813605789839310-2026-10-27-10-06-18-0.snapshot","creation_time":"2026-10-27T10:06:18","size":74451968,"checksum":"062b7bda…"},"status":"ok","time":0.171302321} ``` Creating it took 0.4 s; the 74 MB download took 28.7 s on our connection. To restore inside the VM without uploading anything, point Qdrant at the file it already wrote: ```bash sbx exec $id --timeout 120 'curl -s -X PUT -H "api-key: $(cat ~/qdrant.key)" -H "content-type: application/json" \ -d "{\"location\":\"file:///qdrant/snapshots/products/products-4813605789839310-2026-10-27-10-06-18-0.snapshot\"}" \ "http://127.0.0.1:6333/collections/restored/snapshots/recover?wait=true"' ``` ```text {"result":true,"status":"ok","time":0.262814712} ``` A count on `restored` returned 20,000. This is also how you move a collection to a new sandbox: download the snapshot, upload it to the new one, recover. ## 5. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id sbx exec $id 'docker ps --format "{{.Names}} {{.Status}}"' ``` ```text qdrant Up 11 minutes ``` The resume reported `memoryRestored: true`. Qdrant was never restarted, and `remote.py` worked again with the same hosted URL and cookie. A resume boots from disk instead when no machine with the same CPU type is free, or when you change the size. We forced that with `sbx resume $id --type default` (`memoryRestored: false`, `resumeReason: "resized"`). Docker's restart policy started Qdrant again (`qdrant Up 1 second`), both collections still held 20,000 points, and the `--localhost` hosted URL answered `remote.py` with the same results. Moving to a smaller size isn't supported yet: create a new sandbox and move the data with a snapshot. See [snapshots](/docs/snapshots#resume). ## 6. Clean up ```bash sbx delete $id --yes ``` A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. ## FAQ ### Do I still need a Qdrant API key if the hosted URL is private? We recommend it. The private URL's token is one secret that anyone holding the full link can use. The API key is a second secret that Qdrant checks itself, and it also stops other processes in the sandbox, such as an agent's code, from writing to your collections. ### Why is my collection green but indexed_vectors_count lower than points_count? Qdrant only builds an HNSW index for segments larger than `indexing_threshold` (10,000 KB by default). Smaller segments are searched by a full scan. In our run, 14,000 of 20,000 vectors were indexed and the collection was `green`. Wait for `green`, not for the counts to match. ### Why does qdrant-client fail with "Connection refused" on the hosted URL? It adds port 6333 to a URL that has no port. Pass `port=443`. The hosted URL serves HTTPS on 443 only. ### Can I use the gRPC port through the hosted URL? We tested the REST API on port 6333 only. The hosted URL serves HTTPS with HTTP/2, but we didn't run Qdrant's gRPC API (port 6334) through it, so we can't say it works. ### Is a small sandbox big enough? For our 20,000 points of 384 dimensions, yes: the whole run used the 2 vCPU, 4 GB `small` size. By default Qdrant [preloads its on-disk vectors into memory](https://qdrant.tech/documentation/concepts/storage/) (checked October 27, 2026), so 384 float32 values cost about 1.5 KB per point before the index. Size up when your collection grows toward the sandbox's memory, or use Qdrant's on-disk and quantization options. ## Related - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Hosting](/docs/hosting) --- # How to Run a Chroma Vector Database in a Cloud Sandbox Source: https://sandboxer.com/guides/run-chroma-vector-database-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the chromadb/chroma Docker image in a Sandboxer microVM with a named volume and --restart unless-stopped, host port 8000 on a private HTTPS URL, and give the Python HttpClient the URL's auth cookie as a header. Our query took about 2 ms inside the VM and 129 ms from outside. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB). The server was the `chromadb/chroma` image (`latest` at the time, which reported `chroma 1.4.4`). The Python client was `chromadb` 1.5.9, both in the sandbox and on our client machine. The two versions worked together for everything on this page. The data is real text: the 27 Markdown pages of the Sandboxer docs, split into 430 paragraphs. Chroma embedded them with its default model (`all-MiniLM-L6-v2`, run on the CPU through ONNX). There is no GPU in this setup. | Step | Result | | - | - | | Pull the image and start Chroma | 7.3 s | | First `add` (downloads the 79 MB default model) | 6.1 s | | Embed and add 429 paragraphs on the sandbox CPU | 12.6 s (34 paragraphs per second) | | Embed one query on the sandbox CPU | 248 to 302 ms | | Top-5 query inside the VM (vector given) | 1.8 to 2.1 ms median over 20 runs | | Same query from outside, over the private hosted URL | 128 to 129 ms median over 20 runs | | Private URL without the token | `401` | | `sbx stop --wait` then `sbx resume` | Memory restored: Chroma kept running, same URL and token worked | | Delete and re-create the container on the same volume | Ready in 0.2 s, all 430 records there | If you want vectors next to relational data in SQL, see [Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) instead. For a vector server where you send your own embeddings and set an API key, see [Qdrant in a sandbox](/guides/run-qdrant-vector-database-in-a-sandbox). This page is about Chroma's own HTTP server and its Python client. ## 1. Start Chroma in a sandbox Chroma documents a one-line Docker install ([Chroma Docker guide](https://docs.trychroma.com/guides/deploy/docker)). Docker is preinstalled in every sandbox. We added a restart policy and used a named volume instead of a bind mount: ```bash sbx new --name chroma --type default --no-env id=sb_m8h798mxvr # the id that sbx new printed sbx exec $id --timeout 300 "docker run -d --name chroma --restart unless-stopped \ -p 127.0.0.1:8000:8000 -v chroma-data:/data chromadb/chroma" sbx exec $id "until curl -sf localhost:8000/api/v2/heartbeat; do sleep 0.5; done; echo; docker exec chroma chroma --version" ``` ```text Status: Downloaded newer image for chromadb/chroma:latest {"nanosecond heartbeat":1793095888453411535} chroma 1.4.4 ``` Chroma stores everything under `/data` in the container, in a SQLite file (`chroma.sqlite3`) plus index files. After loading our 430 paragraphs, the `chroma-data` volume was 2.4 MB. For anything you keep, pin the version you tested (`chromadb/chroma:1.4.4` here) instead of `latest`. ## 2. Load documents, embedding them in the sandbox Chroma's Python client computes embeddings on the client side. With no `embedding_function` set, it uses `all-MiniLM-L6-v2`, which it downloads to `~/.cache/chroma` on first use. Running the loader inside the sandbox means the model download and the CPU work happen there, not on your laptop. Install the client with `uv` (preinstalled): ```bash sbx exec $id --timeout 600 "bash -lc 'uv venv -q -p 3.12 ~/venv && VIRTUAL_ENV=~/venv uv pip install -q chromadb==1.5.9'" ``` This is `ingest.py`. It splits each Markdown file on blank lines, skips code blocks and short lines, and adds the rest in batches of 100. The `configuration` argument sets cosine distance for the collection's HNSW index: ```python import glob, os, time import chromadb paras = [] for path in sorted(glob.glob("docs/*.md")): text = open(path).read().split("\n---\n", 1)[-1] # drop frontmatter for i, p in enumerate(text.split("\n\n")): p = p.strip() if len(p) > 80 and not p.startswith("```"): paras.append((f"{os.path.basename(path)[:-3]}#{i}", p)) client = chromadb.HttpClient(host="localhost", port=8000) col = client.get_or_create_collection("docs", configuration={"hnsw": {"space": "cosine"}}) t0 = time.perf_counter() col.add(ids=[paras[0][0]], documents=[paras[0][1]]) # first call loads the default model print(f"first add (model load): {time.perf_counter() - t0:.1f} s") t0 = time.perf_counter() for i in range(1, len(paras), 100): batch = paras[i:i + 100] col.add(ids=[b[0] for b in batch], documents=[b[1] for b in batch]) dt = time.perf_counter() - t0 print(f"added {len(paras) - 1} paragraphs in {dt:.1f} s ({(len(paras) - 1) / dt:.0f}/s)") print("count:", col.count()) ``` ```bash sbx scp ./docs.tgz $id:/home/user/ # the docs folder and ingest.py sbx exec $id --timeout 600 "tar xzf docs.tgz && ~/venv/bin/python ingest.py" ``` ```text first add (model load): 6.1 s added 429 paragraphs in 12.6 s (34/s) count: 430 ``` The 6.1 seconds include the 79 MB model download. The 34 paragraphs per second is the embedding model on 4 vCPU; the database writes are a small part of it. The download progress bar goes to stderr, so add `2>/dev/null` if it clutters your logs. To see what the server itself costs, we timed a query with the vector already computed (`lat.py`, run in the sandbox): ```python import statistics, time import chromadb col = chromadb.HttpClient(host="localhost", port=8000).get_collection("docs") t0 = time.perf_counter() q = col._embedding_function(["how do I keep a process running after the sandbox resumes"]) print(f"embed one query on the sandbox CPU: {(time.perf_counter() - t0) * 1000:.0f} ms") times = [] for _ in range(20): t0 = time.perf_counter() col.query(query_embeddings=q, n_results=5) times.append((time.perf_counter() - t0) * 1000) print(f"query inside the VM: median {statistics.median(times):.1f} ms, min {min(times):.1f} ms (20 runs)") ``` ```text embed one query on the sandbox CPU: 302 ms query inside the VM: median 2.1 ms, min 1.7 ms (20 runs) ``` A second run gave 248 ms and 1.8 ms. At this size, embedding the question takes about 100 times longer than searching for it. `_embedding_function` is a private attribute; we used it only to split the timing. In your own code, pass `query_texts` and let Chroma embed. ## 3. Query it from your machine over a private URL Chroma speaks HTTP, so unlike Postgres it can go on a [hosted URL](/docs/hosting) instead of an SSH tunnel. Hosted URLs are private by default: ```bash sbx host $id 8000 --localhost --title Chroma curl -s -o /dev/null -w "%{http_code}\n" https://fine-civic-aspen-8000.box.sandboxer.com/api/v2/heartbeat ``` ```text https://fine-civic-aspen-8000.box.sandboxer.com?_token=<token> 401 ``` A browser handles the `?_token=` for you: the first request answers `302` and sets an `__sbx_auth` cookie. Chroma's `HttpClient` takes a host and port, not a URL with a query string, and it doesn't keep cookies. Without the cookie, every call gets the proxy's `401` page, which the client reports as a `ValueError`: ```text ValueError <!doctype html><html><head><meta charset="utf-8">…<title>Access token required ``` So trade the token for the cookie once, then pass the cookie as a header on every request. This is `query.py`; it reads the full private URL from an environment variable so the token stays out of your code: ```python # CHROMA_URL="https://…-8000.box.sandboxer.com?_token=…" python query.py import os, statistics, time from urllib.parse import urlsplit import requests, chromadb url = urlsplit(os.environ["CHROMA_URL"]) r = requests.get(os.environ["CHROMA_URL"], allow_redirects=False) # trade ?_token= for the cookie cookie = r.headers["set-cookie"].split(";")[0] client = chromadb.HttpClient(host=url.hostname, port=443, ssl=True, headers={"Cookie": cookie}) print("heartbeat ok:", client.heartbeat() > 0) col = client.get_collection("docs") print("count:", col.count()) res = col.query(query_texts=["how do I keep a process running after the sandbox resumes"], n_results=3) for i, d in zip(res["ids"][0], res["distances"][0]): print(f"{d:.3f} {i}") q = col._embedding_function(["how do I keep a process running after the sandbox resumes"]) times = [] for _ in range(20): t0 = time.perf_counter() col.query(query_embeddings=q, n_results=5) times.append((time.perf_counter() - t0) * 1000) print(f"query over hosted URL: median {statistics.median(times):.0f} ms, min {min(times):.0f} ms (20 runs)") ``` ```text heartbeat ok: True count: 430 0.251 openai-agents#22 0.297 snapshots#22 0.320 faq#3 query over hosted URL: median 129 ms, min 128 ms (20 runs) ``` The three hits are on topic. The third one, from the FAQ, starts: "Stop freezes the filesystem, writes the VM's memory and device state to disk, and shuts it down. Resume restores that memory, so processes keep running…". Filters work over the same connection too: `col.get(where_document={"$contains": "memoryRestored"})` returned the three paragraphs of the snapshots page that mention that field. Two things to know about the client side: - **The client embeds the question.** `query_texts` runs `all-MiniLM-L6-v2` on your machine, so the first query there downloads the same 79 MB model. Whatever embeds your queries must be the same model that embedded your documents, or the distances mean nothing. - **Most of the 129 ms is the network.** The search takes about 2 ms in the VM; the rest is the round trip from our client to the sandbox through the HTTPS proxy, and it depends on where you are. An agent that queries in a loop should run in the same sandbox and use `localhost:8000`. The cookie is the route's access token, so treat it like the URL: keep it out of logs. To cut off everyone who has the link, run `sbx host $id 8000 --localhost --public` and then the same with `--private`; that mints a new token. Keep `--localhost` on every `sbx host` call for this port, since each call sets the relay mode again. `sbx host hide $id 8000` takes the URL down and leaves Chroma running. Chroma also has its own server configuration file ([Chroma Docker guide](https://docs.trychroma.com/guides/deploy/docker), mounted at `/config.yaml`). We didn't configure Chroma's own auth here: the private URL was the only gate we tested. ### A note on the published port The commands above publish Chroma as `-p 127.0.0.1:8000:8000` and host it with `--localhost`, so the hosted URL relays to the VM's loopback. The sandbox also has a public IPv6 address. In our check on October 27, 2026, its firewall (`ufw`, closed except SSH) blocked a port Docker published on all interfaces, but Docker can [bypass ufw rules](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw) on other setups. Binding to `127.0.0.1` keeps the private URL the only way in, whatever the firewall does. Our test run used `-p 8000:8000` and a plain `sbx host`; the timings above come from that run. ## 4. What survives a stop, a resume and an upgrade ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker ps --format '{{.Names}} {{.Status}}'" ``` ```text chroma Up About a minute ``` The stop took 2.2 seconds and the resume 1.4 seconds, with `memoryRestored: true`. The container was never restarted. The hosted URL kept the same token, and `query.py` returned the same three hits and a 128 ms median. When a resume boots from disk instead (no machine with the same CPU type is free, or you change the size), running processes are gone. `--restart unless-stopped` makes Docker start Chroma again, and the data is on the `chroma-data` volume. See [snapshots](/docs/snapshots#resume) for when each path happens. The same volume is how you upgrade. We removed the container and started a new one with the same command: ```bash sbx exec $id --timeout 120 "docker rm -f chroma >/dev/null; docker run -d --name chroma --restart unless-stopped \ -p 127.0.0.1:8000:8000 -v chroma-data:/data chromadb/chroma >/dev/null; \ until curl -sf localhost:8000/api/v2/heartbeat >/dev/null; do sleep 0.2; done; \ ~/venv/bin/python -c 'import chromadb; print(chromadb.HttpClient().get_collection(\"docs\").count())'" ``` ```text 430 ``` The new container answered in 0.2 seconds with every record in place. To move to a new Chroma version, change the image tag in that command. Check Chroma's release notes first, and fork the sandbox before you try it ([fork copies the disk](/docs/snapshots)), so you can go back. ## 5. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs and nothing while it is stopped. ## FAQ ### Why does chromadb.HttpClient fail with an "Access token required" ValueError? The hosted URL is private and the client sent neither the token nor the cookie. `HttpClient` can't carry `?_token=` in its host, so fetch the URL once with redirects off, take the `__sbx_auth` cookie from the `Set-Cookie` header, and pass it with `headers={"Cookie": cookie}`. ### Where do the embeddings get computed? In the client, not the Chroma server. With the default embedding function, the Python client runs `all-MiniLM-L6-v2` with ONNX wherever the client runs. On a `default` sandbox, that was 34 paragraphs per second for loading and about 250 to 300 ms per query. ### Will my collections survive if the sandbox auto-stops? Yes. An auto-stop is a normal stop: disk and memory are saved and `sbx resume` brings them back. The data also lives on the `chroma-data` Docker volume, so it survives a resume that boots from disk and a re-created container. ### Should I use Chroma or pgvector? Use pgvector if your data is already in Postgres or you want SQL joins and transactions over the same rows. Use Chroma if you want a small HTTP service with a Python client that also embeds text for you. Both ran fine on a `default` sandbox at our sizes; measure at yours. ### Can I make the Chroma URL public? `sbx host $id 8000 --localhost --public` removes the token, so anyone with the URL can read, write and delete your collections. We kept it private. ## Related - [Run Postgres with pgvector in a sandbox](/guides/run-postgres-with-pgvector-in-a-sandbox) - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run a Qdrant vector database in a sandbox](/guides/run-qdrant-vector-database-in-a-sandbox) - [Hosting](/docs/hosting) --- # How to Run OpenSearch and OpenSearch Dashboards in a Sandbox Source: https://sandboxer.com/guides/run-opensearch-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run the official OpenSearch 3 and OpenSearch Dashboards images with Docker Compose in a default Sandboxer sandbox, keep port 9200 inside the VM, and host Dashboards on port 5601 behind a private URL. Both were green 80 seconds after a cold pull, and searches answered in about 14 ms. ## What we ran Everything below ran on production on October 27, 2026, in one `default` sandbox (4 vCPU, 8 GB): OpenSearch 3.9.0 (Lucene 10.5.1) and OpenSearch Dashboards 3.9.0, from the `opensearchproject/opensearch:3` and `opensearchproject/opensearch-dashboards:3` images (1.7 GB each). The timings are single runs, except where we give a range. Yours will differ. | Step | Result | | - | - | | `sbx new` until ready | 3.2 s | | `docker compose up -d` until the cluster is green (pull included) | 76 s | | Same, until Dashboards reports green | 80 s | | `docker compose restart` until both are green again (no pull) | 24 s and 27 s | | Memory after loading data | OpenSearch 2.5 GB (1.2 of 2 GB heap used), Dashboards 270 MB, 4.3 GB of the VM still available | | Bulk index 700 documents | 24 ms in OpenSearch | | Fuzzy `match` query, measured in the VM | 8 to 26 ms round trip, 14 ms median over 20 runs | | Dashboards `/api/status` from a laptop through the hosted URL | 227 to 243 ms over 10 runs | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, no container restarts | We used `default` because the cluster alone held 2.5 GB, and Dashboards is a second process. We did not test `small` (4 GB). If you try it, lower the heap to `-Xms1g -Xmx1g`. ## Two things OpenSearch needs that a sandbox already has OpenSearch's [Docker install guide](https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/) asks for two things on the host. Both are already handled: ```bash sbx exec $id "sysctl vm.max_map_count; docker --version" ``` ```text vm.max_map_count = 1048576 Docker version 29.9.0, build f415da8 ``` - **`vm.max_map_count`**: OpenSearch wants at least 262144. A sandbox ships with 1048576, so you skip the `sysctl -w` step. On a laptop or a stock Docker host, forgetting it is the classic reason the container exits at boot. - **Docker and Compose**: preinstalled, and you can raise ulimits such as `memlock` in the compose file, because the sandbox is a full VM rather than a shared container. ## 1. Write the compose file This is a single-node cluster. It keeps the security plugin on (TLS and a login on port 9200), stores the data in a named volume, and publishes ports 9200 and 5601 only on `127.0.0.1`: ```yaml services: opensearch: image: opensearchproject/opensearch:3 container_name: opensearch environment: - discovery.type=single-node - bootstrap.memory_lock=true - OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} ulimits: memlock: { soft: -1, hard: -1 } nofile: { soft: 65536, hard: 65536 } volumes: - osdata:/usr/share/opensearch/data ports: - 127.0.0.1:9200:9200 restart: unless-stopped dashboards: image: opensearchproject/opensearch-dashboards:3 container_name: dashboards environment: - OPENSEARCH_HOSTS=["https://opensearch:9200"] ports: - 127.0.0.1:5601:5601 depends_on: [opensearch] restart: unless-stopped volumes: osdata: ``` Two choices here matter: - **Only Dashboards gets a URL.** Both ports are bound to `127.0.0.1`, so neither is reachable on the VM's public IPv6 address (in our October 27, 2026 check, the sandbox's `ufw` blocked a Docker-published port on IPv6 too, but binding to loopback doesn't depend on that). Dashboards is hosted with `--localhost` in step 5. You reach the search API through Dashboards (step 6) or inside the VM. Our test run published Dashboards as `5601:5601` and hosted it without `--localhost`; the results below come from that run. - **`restart: unless-stopped`** brings both containers back when a sandbox boots from disk instead of restoring memory. ## 2. Make the admin password in the sandbox and start the stack Since 2.12, OpenSearch refuses to start without a strong `OPENSEARCH_INITIAL_ADMIN_PASSWORD`. Generate it in the sandbox, in a file only your user can read, so it never passes through your terminal or shell history: ```bash sbx new --name search --type default --no-env id=sb_fo6rh387vi # the id that sbx new printed sbx exec $id "mkdir -p search" sbx scp ./compose.yaml $id:/home/user/search/ sbx exec $id "cd search && umask 077 && \ echo OPENSEARCH_INITIAL_ADMIN_PASSWORD=\$(openssl rand -base64 24 | tr -d /+=)-Os9 > .env && ls -l .env" ``` ```text -rw------- 1 user user 71 Oct 27 10:17 .env ``` Docker Compose reads `.env` from the project directory on its own. The pull takes longer than the 30-second default `sbx exec` timeout, so raise it: ```bash sbx exec $id --timeout 600 "cd search && docker compose up -d && set -a && . ./.env && set +a && \ until curl -sk -u admin:\$OPENSEARCH_INITIAL_ADMIN_PASSWORD https://localhost:9200/_cluster/health | grep -q status; do sleep 1; done && \ curl -sk -u admin:\$OPENSEARCH_INITIAL_ADMIN_PASSWORD https://localhost:9200/_cluster/health | jq -c '{status, number_of_nodes}'" ``` ```text Container opensearch Started Container dashboards Started {"status":"green","number_of_nodes":1} ``` The cluster was green after 76 seconds, and about 50 of those were the image pulls. OpenSearch logged "Node started" 25 seconds after its container started. The `-k` flag accepts the demo TLS certificate the image generates for itself. That's fine inside the VM, but don't copy it into anything that talks to a real cluster. ## 3. Index your own documents and search them The scripts below run inside the sandbox. Each one starts by reading the password from `.env` into a variable and never prints it. This one indexes every installed Debian package with its one-line summary (700 documents). It creates an explicit mapping, sends one `_bulk` request, then runs a misspelled full-text query: ```bash #!/bin/bash set -euo pipefail cd ~/search; set -a; . ./.env; set +a A="admin:$OPENSEARCH_INITIAL_ADMIN_PASSWORD" os() { curl -sk -u "$A" -H 'Content-Type: application/json' "$@"; } os -X PUT https://localhost:9200/packages -d '{ "settings": {"number_of_shards": 1, "number_of_replicas": 0}, "mappings": {"properties": {"name": {"type": "keyword"}, "summary": {"type": "text"}}}}' | jq -c . dpkg-query -W -f='${Package}\t${binary:Summary}\n' \ | jq -Rc 'split("\t") | {index: {_id: .[0]}}, {name: .[0], summary: .[1]}' > bulk.ndjson curl -sk -u "$A" -H "Content-Type: application/x-ndjson" -X POST "https://localhost:9200/packages/_bulk?refresh=true" \ --data-binary @bulk.ndjson | jq -c '{took, errors, items: (.items | length)}' q='{"size": 3, "_source": ["name"], "query": {"match": {"summary": {"query": "compresion library", "fuzziness": "AUTO"}}}}' os -X POST https://localhost:9200/packages/_search -d "$q" \ | jq -c '{took, total: .hits.total.value, top: [.hits.hits[] | {(._source.name): (._score*100|round/100)}]}' ``` ```bash sbx scp ./index.sh $id:/home/user/search/ sbx exec $id --timeout 300 "bash search/index.sh" ``` ```text {"acknowledged":true,"shards_acknowledged":true,"index":"packages"} {"took":24,"errors":false,"items":700} {"took":23,"total":339,"top":[{"liblzo2-2":2.27},{"zlib1g":2.27},{"liblzma5":2.09}]} ``` `fuzziness: AUTO` matched "compression" despite the typo, so the three compression libraries rank first. The total is high because "library" alone matches 339 summaries. Three details trip people up: - `_bulk` takes NDJSON with `Content-Type: application/x-ndjson`. With no index in the path and none in each action line, it fails with `index is missing` for every item. We hit that on our first try. - `?refresh=true` makes the documents searchable as soon as the request returns. Leave it off for big loads. - `number_of_replicas: 0` is what keeps a one-node index green. With the default of 1, the replica has nowhere to go and the index stays yellow. The same query, repeated 20 times from inside the VM, took 8.3 ms at best, 13.7 ms median and 26.1 ms at worst, counting curl and TLS. ## 4. Load sample data for Dashboards Dashboards ships sample datasets with ready-made visualizations. You can add one from the home page, or through its API. These lines run in the sandbox after the same first four lines as the script in step 3, which set `A` and `os`. Writes to the Dashboards API need the `osd-xsrf` header: ```bash curl -s -u "$A" -X POST -H 'osd-xsrf: true' localhost:5601/api/sample_data/flights | jq -c . os -X POST https://localhost:9200/opensearch_dashboards_sample_data_flights/_search -d '{ "size": 0, "query": {"term": {"Cancelled": true}}, "aggs": {"carriers": {"terms": {"field": "Carrier", "size": 4}}}}' \ | jq -c '{took, cancelled: .hits.total.value, by_carrier: [.aggregations.carriers.buckets[] | {(.key): .doc_count}] | add}' ``` ```text {"opensearchIndicesCreated":{"opensearch_dashboards_sample_data_flights":13059},"opensearchDashboardsSavedObjectsLoaded":21} {"took":32,"cancelled":1678,"by_carrier":{"BeatsWest":441,"OpenSearch-Air":418,"Logstash Airways":417,"OpenSearch Dashboards Airlines":402}} ``` That gives you 13,059 flight documents (5.7 MB on disk) and 21 saved objects, including the "[Flights] Global Flight Dashboard". ## 5. Put Dashboards on a private URL ```bash sbx host $id 5601 --localhost --title "OpenSearch Dashboards" ``` ```text https://vast-pacific-glade-5601.box.sandboxer.com?_token= ``` This URL is protected twice. The Sandboxer proxy turns away anyone without the token, and Dashboards asks for its own login after that: ```bash B=https://vast-pacific-glade-5601.box.sandboxer.com curl -si $B/ | head -1 # no token curl -si -c jar "$B?_token=" | grep -iE '^HTTP|^location' # token, sets a cookie curl -s -b jar -o /dev/null -w '%{http_code}\n' $B/api/status # cookie, no Dashboards login curl -sL -b jar -o /dev/null -w '%{http_code} %{url_effective}\n' $B/app/home ``` ```text HTTP/2 401 HTTP/2 302 location: / 401 200 https://vast-pacific-glade-5601.box.sandboxer.com/app/login?nextUrl=%2Fapp%2Fhome ``` Open the `?_token=` URL in a browser and sign in as `admin` with the password from `.env`. This is the one step that shows it: run `sbx exec $id "cut -d= -f2- search/.env"`, copy it straight into your password manager, and clear your terminal. How the token and its 30-day cookie work is covered in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). ## 6. Query OpenSearch from your laptop without exposing 9200 Dashboards' Dev Tools console sends requests to OpenSearch through its own `/api/console/proxy` endpoint. A script can use that endpoint too, so the search API is reachable from outside while port 9200 stays on `127.0.0.1`. Here `PW` holds the admin password in a shell variable on your laptop: ```bash curl -s -b jar -u "admin:$PW" -H 'osd-xsrf: true' -H 'Content-Type: application/json' \ -X POST "$B/api/console/proxy?path=packages/_search&method=POST" \ -d '{"size":2,"_source":["name","summary"],"query":{"match":{"summary":"ssh client"}}}' \ | jq -c '{took, top: [.hits.hits[]._source]}' ``` ```text {"took":8,"top":[{"summary":"secure shell (SSH) client, for secure access to remote machines","name":"openssh-client"},{"summary":"tiny C SSH library (OpenSSL flavor)","name":"libssh-4"}]} ``` From our laptop, a Dashboards API call through the hosted URL took 227 to 243 ms. Nearly all of that is the network path. For a client library that wants a real `https://host:9200`, forward the port over SSH instead (`ssh -L 9200:127.0.0.1:9200`), as shown for MongoDB in [Run MongoDB in a sandbox](/guides/run-mongodb-in-a-sandbox). ## 7. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id sbx exec $id "docker inspect -f '{{.Name}} restarts={{.RestartCount}}' opensearch dashboards; \ cd search; set -a; . ./.env; set +a; curl -sk -u admin:\$OPENSEARCH_INITIAL_ADMIN_PASSWORD https://localhost:9200/_cat/count/packages" ``` ```text /opensearch restarts=0 /dashboards restarts=0 1793096415 10:20:15 700 ``` The stop took 2.2 seconds and the resume 1.3 seconds, with `memoryRestored: true`. Neither container restarted: OpenSearch and Dashboards kept running with the memory they had before the stop. A resume boots from disk instead when no machine with the same CPU type is free, or when you resume on a larger size. Then `restart: unless-stopped` starts both containers again, and the indices come back from the `osdata` volume. A `docker compose restart` is the closest test of that path: the cluster was green in 24 seconds and Dashboards in 27, with all 13,759 documents in place. The [snapshots docs](/docs/snapshots#resume) give the rules. An idle sandbox pauses after `idleStopMinutes`, and a pause is a stop with memory. Requests to the hosted URL count as activity. To keep a shared dashboard awake, see [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use). ## 8. Clean up ```bash sbx delete $id --yes ``` A `default` sandbox costs $0.036/hour while it runs. A stopped one has no running charge. ## FAQ ### Do I need to set vm.max_map_count in a sandbox? No. A sandbox already has `vm.max_map_count = 1048576`, and OpenSearch asks for at least 262144. The compose file only needs the `memlock` and `nofile` ulimits. ### Can I turn off the security plugin to make it simpler? You can, but then port 9200 and Dashboards have no login at all. Keep it on, as above, especially if you host Dashboards. The Sandboxer token stops strangers, but anyone you give the link to would get full write access. ### Why does my index show yellow health? On a single node, an index with the default one replica has no second node to put that replica on. Set `"number_of_replicas": 0` when you create the index, or change it later through the `_settings` API. ### How much memory does OpenSearch use here? With a 2 GB heap, the OpenSearch container used 2.5 GB and Dashboards 270 MB after we loaded 13,759 documents. That left 4.3 GB free on a `default` sandbox. Set the heap through `OPENSEARCH_JAVA_OPTS`. Leave room for the operating system's file cache and for Dashboards. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Run Meilisearch in a sandbox](/guides/run-meilisearch-in-a-sandbox) - [Hosting](/docs/hosting) --- # How to Run a Streamlit App in a Cloud Sandbox With an HTTPS URL Source: https://sandboxer.com/guides/run-a-streamlit-app-in-a-sandbox. Updated 2026-10-27. **Short answer:** Install Streamlit in a venv, run it as a systemd service on port 8501, then run sbx host 8501. The private URL's token gates the page and the /_stcore/stream WebSocket. Keep Streamlit's CORS and XSRF protection on: uploads and reruns worked through the proxy with the default settings. ## What we ran Everything below ran on prod on 2026-10-27, on one `small` sandbox (2 vCPU, 4 GB) with Python 3.12.3 and Streamlit 1.66.1. Tokens and cookies are replaced with `` and ``. For how hosted URLs, tokens and cookies work in general, see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). This page covers what is specific to Streamlit. | Step | Result | | - | - | | `pip install streamlit` in a fresh venv | 18.5 s | | Service start to `/_stcore/health` answering `ok` | 0.62 s | | App drawn in headless Chromium through the private URL | 2.0 s from the first request | | Request or WebSocket without the token | `401` | | File upload with CORS and XSRF protection left on | Worked | | Browser tab left open across `sbx stop --wait` and `sbx resume` | Reconnected, `st.session_state` kept | | Process killed with `kill -9` | systemd restarted it, healthy again in 0.87 s | ## Why Streamlit needs a WebSocket-capable proxy A Streamlit page is almost empty HTML. The app itself arrives over one WebSocket at `/_stcore/stream`: every widget, every rerun and every chart goes through it. A proxy that serves the HTML but drops the WebSocket upgrade gives you a page that spins forever. Sandboxer's hosting proxy passes WebSockets through ([Hosting](/docs/hosting)), so nothing needs to change in Streamlit. The other endpoints that matter: | Path | What it does | Through a private hosted URL | | - | - | - | | `/` | HTML shell | `401` without the token, `200` with the cookie | | `/_stcore/stream` | The app's WebSocket | `401` without the cookie, `101` with it | | `/_stcore/health` | Returns `ok` when the server is up | `401` without the cookie, `ok` with it | | `/_stcore/upload_file/…` | `st.file_uploader` uploads, checked against the `_streamlit_xsrf` cookie | Worked from the browser | ## 1. Install Streamlit The [Streamlit install docs](https://docs.streamlit.io/get-started/installation/command-line) use a virtual environment and `pip install streamlit`. The sandbox already has Python 3.12: ```bash sbx new --name streamlit --type small sbx exec sb_emnyl4g91w --timeout 600 "mkdir -p ~/st-app && cd ~/st-app && python3 -m venv .venv && time .venv/bin/pip install -q streamlit; .venv/bin/streamlit version" ``` ```text real 0m18.533s … Streamlit, version 1.66.1 ``` `sbx exec` stops after 30 seconds by default, so give installs a `--timeout`. ## 2. A test app This app exercises the parts that break behind a bad proxy: a button that changes `st.session_state` (WebSocket round trips), a cached function, and a file upload (XSRF check). Save it as `~/st-app/app.py` in the sandbox: ```python import time import pandas as pd import streamlit as st st.title("Hello from a sandbox") if "clicks" not in st.session_state: st.session_state.clicks = 0 if st.button("Click me"): st.session_state.clicks += 1 st.write(f"Clicks this session: {st.session_state.clicks}") @st.cache_data def slow_square(n: int) -> int: time.sleep(2) return n * n t = time.perf_counter() v = slow_square(12) st.write(f"slow_square(12) = {v} in {time.perf_counter() - t:.2f} s") up = st.file_uploader("Upload a CSV", type="csv") if up is not None: df = pd.read_csv(up) st.write(f"Uploaded {up.name}: {len(df)} rows, columns {list(df.columns)}") ``` ## 3. Run it as a service Running `streamlit run` from `sbx exec --detach` works, but a systemd unit restarts it if it crashes and starts it again when the sandbox boots from disk: ```bash sbx exec sb_emnyl4g91w 'sudo tee /etc/systemd/system/streamlit.service >/dev/null < ``` Without the token, the proxy answers and Streamlit never sees the request. That includes the WebSocket: ```bash curl -si https://leafy-lunar-bugle-8501.box.sandboxer.com/ | head -1 curl -s -o /dev/null -w "%{http_code}\n" https://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/health ``` ```text HTTP/2 401 401 ``` The token link returns a `302` that sets the 30-day `__sbx_auth` cookie. With that cookie, the WebSocket handshake to `/_stcore/stream` returned `101 Switching Protocols`, and without it `401`. A browser sends the cookie on its own, so opening the token link is all a viewer has to do. We opened the link in headless Chromium (Playwright 1.64.1), clicked the button three times, reloaded, and uploaded a three-row CSV: ```text first response: 200 app rendered after 2004 ms after 3 clicks: Clicks this session: 3 after reload: Clicks this session: 0 | slow_square(12) = 144 in 0.11 s Uploaded temps.csv: 3 rows, columns ['city', 'temp'] cookies: __sbx_auth, ajs_anonymous_id, _streamlit_xsrf websocket: wss://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/stream frames received: 18 websocket: wss://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/stream frames received: 23 ``` A reload starts a new session, so `st.session_state` goes back to 0, while `st.cache_data` is shared by the server process, so the 2-second function came back in 0.11 seconds. `ajs_anonymous_id` comes from Streamlit's usage statistics; turn it off with `browser.gatherUsageStats = false` ([config options](https://docs.streamlit.io/develop/api-reference/configuration/config.toml)). ## Leave CORS and XSRF protection on Answers for running Streamlit behind a proxy often say to start it with `--server.enableCORS false --server.enableXsrfProtection false`. On a hosted URL you don't need either: - The page, the WebSocket and the upload all come from the same origin (`https://-8501.box.sandboxer.com`). Streamlit's origin check passes and the `_streamlit_xsrf` cookie matches, so the upload above worked with both protections on. - The origin check is doing real work. Inside the sandbox, a WebSocket handshake with a foreign `Origin` header got `403` from Streamlit, while the same handshake with the hosted URL as `Origin` got `101`: ```bash # inside the sandbox, straight to Streamlit Origin https://evil.example -> 403 Origin https://leafy-lunar-bugle-8501.box.sandboxer.com -> 101 ``` Streamlit's [config docs](https://docs.streamlit.io/develop/api-reference/configuration/config.toml) also note that the two settings conflict, and that XSRF protection takes priority if it is on while CORS is off. If an upload fails with a `403`, check that you opened the app on its hosted URL and not through a second proxy or an iframe on another domain. ## Public or private? | | Private (default) | `sbx host 8501 --public` | | - | - | - | | Who can open it | Anyone with the token link or its cookie | Anyone | | WebSocket needs | The `__sbx_auth` cookie, which the browser has after the token link | Nothing | | Use it for | Internal tools, apps that read your data or secrets | Demos with no sensitive data | Streamlit has no login of its own in this setup. A public URL runs your app, with its database credentials and API keys, for any visitor. Switching public and back to private mints a new token, which cuts off old links. ## Stop, resume and crashes We left the browser tab open (after clicking twice), stopped the sandbox, waited 20 seconds, and resumed it: ```bash sbx stop sb_emnyl4g91w --wait # 2.2 s; the URL returned 404 while stopped sbx resume sb_emnyl4g91w # 1.3 s, memoryRestored: true ``` ```text before stop: Clicks this session: 2 after resume + 1 click: Clicks this session: 3 (1528 ms) ``` The Streamlit process was the same one (`MainPID=673` before and after). The open tab's WebSocket reconnected to it, and the session kept its state, so the next click counted to 3. Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk (`memoryRestored: false`): the venv, the app and the hosted route are kept, and the enabled unit starts Streamlit again, but sessions and `st.cache_data` start empty. We didn't force a disk-only boot in this test. Crash recovery is quick. After `sudo kill -9` on the Streamlit process, systemd started a new one (`NRestarts=1`) and `/_stcore/health` answered again 0.87 seconds later. A new process has no sessions, so state from before the crash is gone. ## FAQ ### Why does my Streamlit page spin forever? The HTML loaded but the `/_stcore/stream` WebSocket did not connect. On a private URL that happens when the handshake has no `__sbx_auth` cookie, for example in a script that sent `?_token=` on the WebSocket URL. Open the token link first, or see the cookie example in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). ### Do I need --server.address 0.0.0.0? No. With no address set, Streamlit 1.66.1 listened on all interfaces (`*:8501`). If you set `server.address` to `127.0.0.1`, host the port with `--localhost`. ### Should I turn off enableCORS or enableXsrfProtection? No. The hosted URL serves everything from one origin, so uploads and the WebSocket work with both on. Turning them off removes Streamlit's check against other sites driving your app. ### Which size do I need? The test app ran on `small` (2 vCPU, 4 GB) with most of the memory free. Pick `default` (4 vCPU, 8 GB) if your app loads large dataframes or models. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Run code-server (VS Code in the browser)](/guides/run-code-server-vs-code-in-the-browser) - [Run a Gradio app in a sandbox](/guides/run-a-gradio-app-in-a-sandbox) - [Hosting](/docs/hosting) --- # How to Run a Gradio App in a Cloud Sandbox and Call Its API Source: https://sandboxer.com/guides/run-a-gradio-app-in-a-sandbox. Updated 2026-10-27. **Short answer:** Install Gradio in a venv, launch with server_name="0.0.0.0", run it as a systemd service and host port 7860 on a private URL. To call it from gradio_client, trade the URL's _token for the proxy's cookie and pass it in headers. Our calls took about 0.6 seconds each. ## What we ran Everything below ran on production on October 27, 2026, on a `small` sandbox (2 vCPU, 4 GB), with Gradio 6.30.0 on the sandbox's Python 3.12 and `gradio_client` 2.7.2 on a laptop. Timings are single runs. | Step | Result | | - | - | | `pip install gradio` into a fresh venv | 33 s, 347 MB | | `systemctl restart gradio` until port 7860 answers | 2.4 to 2.9 s | | Gradio process memory (RSS) | 139 MiB | | Request without the URL token | `401` from the proxy | | `client.predict(...)` from the laptop, with a file output | 588 to 612 ms per call | | REST call with `curl` (POST, then read the result stream) | 0.4 s | | `sbx stop --wait`, then `sbx resume` | 2.2 s stop, 1.3 s resume, `memoryRestored: true` | | Reboot until the hosted URL answers again | 5.7 s | A `small` sandbox is plenty for a CPU-only Gradio demo like this one. If your app loads a model, size for the model, not for Gradio. ## 1. Install Gradio and write the app Gradio's [quickstart](https://www.gradio.app/guides/quickstart) installs it with pip. Python 3.12 is preinstalled, so make a venv and install into it. The install takes longer than `sbx exec`'s 30-second default, so raise the timeout: ```bash sbx new --name gradio --type small --no-env id=sb_r50cwqkv4c # the id that sbx new printed sbx exec $id --timeout 600 "mkdir -p ~/app && cd ~/app && python3 -m venv .venv && .venv/bin/pip install -q gradio" ``` The test app counts words and returns two outputs: a JSON object and a CSV file. The file output matters below, because files are where proxies usually break. ```python # app.py import collections, csv, re, tempfile import gradio as gr def word_counts(text: str, top_n: float): words = re.findall(r"[a-z']+", text.lower()) top = collections.Counter(words).most_common(int(top_n)) path = tempfile.NamedTemporaryFile(suffix=".csv", delete=False).name with open(path, "w", newline="") as f: csv.writer(f).writerows([("word", "count"), *top]) return dict(top), path demo = gr.Interface( word_counts, [gr.Textbox(label="Text", lines=6), gr.Slider(1, 20, value=5, step=1, label="Top N")], [gr.JSON(label="Counts"), gr.File(label="CSV")], ) demo.launch(server_name="0.0.0.0", server_port=7860) ``` ```bash sbx scp app.py $id:app/app.py ``` `server_name="0.0.0.0"` is the line people miss. Gradio listens on `127.0.0.1` by default, and the hosting proxy then answers `502` ("Nothing is answering on port 7860"). Either bind to `0.0.0.0` as above (or set `GRADIO_SERVER_NAME=0.0.0.0`), or host the port with `--localhost`. Don't pass `share=True`: you don't need Gradio's share link when the sandbox already has an HTTPS URL. ## 2. Run it as a systemd service A process started from `sbx exec` is gone after a reboot or a resume that boots from disk. A systemd unit comes back on its own: ```bash sbx exec $id "sudo tee /etc/systemd/system/gradio.service >/dev/null <<'EOF' [Unit] Description=Gradio app After=network-online.target [Service] User=user WorkingDirectory=/home/user/app ExecStart=/home/user/app/.venv/bin/python app.py Environment=GRADIO_ANALYTICS_ENABLED=False Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl enable --now gradio" ``` Plain `sbx exec` doesn't forward your local stdin; `sbx exec -i` does. We copied the file with `sbx scp` and wrote the unit with a heredoc inside the remote command. After `systemctl restart gradio`, port 7860 answered in 2.4 to 2.9 seconds; most of that is importing Gradio. ## 3. Host port 7860 on a private URL ```bash sbx host $id 7860 --title "Word counts" ``` ```text https://global-vital-parrot-7860.box.sandboxer.com?_token= ``` Open that link in a browser and the Gradio UI loads. The proxy sets a 30-day `__sbx_auth` cookie and redirects to `/`, and the cookie carries the page's later requests, including the result stream. Without the token, nothing reaches Gradio, not even its API: ```bash B=https://global-vital-parrot-7860.box.sandboxer.com curl -s -o /dev/null -w "%{http_code}\n" $B/ curl -s -o /dev/null -w "%{http_code}\n" $B/gradio_api/info curl -si -c jar "$B?_token=" | grep -iE '^HTTP|^location' ``` ```text 401 401 HTTP/2 302 location: / ``` The token, the cookie and switching between public and private are covered in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). This guide covers what's specific to Gradio: calling the app from code. ## 4. Call the app from gradio_client [`gradio_client`](https://www.gradio.app/guides/getting-started-with-the-python-client) is Gradio's Python client (`pip install gradio_client`). Give it the private URL as is and it fails in both forms: ```text base URL, no cookie -> AuthenticationError Could not load https://global-vital-parrot-7860.box.sandboxer.com/ as credentials were not provided. Please login. URL with ?_token= -> AuthenticationError Could not load https://global-vital-parrot-7860.box.sandboxer.com?_token= as credentials were not provided. Please login. ``` The client builds its own paths from the base URL and drops the query string, so the token never reaches the proxy. Trade the token for the cookie first and pass it with `headers=`: ```python # client.py: reads the private hosted URL, with its ?_token=, from ./url import time, httpx from gradio_client import Client private_url = open("url").read().strip() # Trade the ?_token= for the proxy's __sbx_auth cookie. r = httpx.get(private_url, follow_redirects=False) cookie = r.headers["set-cookie"].split(";")[0] base = private_url.split("?")[0] client = Client(base, headers={"Cookie": cookie}, verbose=False, analytics_enabled=False, download_files="downloads") print(client.view_api(print_info=False, return_format="dict")["named_endpoints"].keys()) text = "the cat sat on the mat and the dog sat too" for i in range(5): t = time.perf_counter() counts, csv_path = client.predict(text, 3, api_name="/word_counts") print(f"{(time.perf_counter() - t) * 1000:.0f} ms", counts) print(open(csv_path).read()) ``` ```text dict_keys(['/word_counts']) 612 ms {'the': 3, 'sat': 2, 'cat': 1} 588 ms {'the': 3, 'sat': 2, 'cat': 1} 595 ms {'the': 3, 'sat': 2, 'cat': 1} 593 ms {'the': 3, 'sat': 2, 'cat': 1} 593 ms {'the': 3, 'sat': 2, 'cat': 1} word,count the,3 sat,2 cat,1 ``` Three things to notice: - **The endpoint name is the function name.** Gradio named it `/word_counts` without any `api_name` in the code. `view_api()` lists the names if you aren't sure. - **File outputs come back through the same URL.** `predict` downloaded the CSV through the proxy with the same cookie. Each call includes that download, which is part of the roughly 0.6 seconds. - **The cookie is a credential.** It works for 30 days, like the token. Keep both out of logs and source code. Don't swap in Gradio's own `auth=` login for this. It adds a password to the app, but the proxy's token is what keeps requests from reaching it at all. If you make the port `--public` instead, anyone with the URL can call your API, and `Client(base)` then works with no headers. ## 5. Call it with curl Gradio also has a plain [HTTP API](https://www.gradio.app/guides/querying-gradio-apps-with-curl): POST the inputs, get an `event_id`, then read the result as a server-sent event stream. Through a private URL, send the cookie jar from step 3 with both requests: ```bash EID=$(curl -s -b jar -H 'Content-Type: application/json' -X POST $B/gradio_api/call/word_counts \ -d '{"data": ["the cat sat on the mat and the dog sat too", 3]}' \ | python3 -c 'import json, sys; print(json.load(sys.stdin)["event_id"])') curl -s -b jar -N $B/gradio_api/call/word_counts/$EID ``` ```text event: complete data: [{"the": 3, "sat": 2, "cat": 1}, {"path": "/tmp/gradio/4427…/tmpp424nhik.csv", "url": "https://global-vital-parrot-7860.box.sandboxer.com/gradio_api/file=/tmp/gradio/4427…/tmpp424nhik.csv", "size": 33, "orig_name": "tmpp424nhik.csv", "mime_type": null, "is_stream": false, "meta": {"_type": "gradio.FileData"}}] ``` Both requests together took 0.4 seconds. The file's `url` already uses the public `box.sandboxer.com` hostname, so we didn't need to set `root_path` or any proxy option in Gradio. Fetch it with the same cookie jar. The same POST without the cookie got `401`. This form suits agents and shell scripts that can't install `gradio_client`. ## 6. What survives a stop and resume ```bash sbx stop $id --wait sbx resume $id sbx exec $id "systemctl show gradio -p MainPID -p ActiveEnterTimestamp" ``` ```text MainPID=1920 ActiveEnterTimestamp=Tue 2026-10-27 10:03:10 UTC ``` The stop took 2.2 seconds and the resume 1.3 seconds, with `memoryRestored: true`. The service's start time was still from before the stop and the VM had not rebooted, so Gradio never restarted. `client.py`, run again with the same private URL, returned the same counts. Routes and tokens survive a stop, so links you already shared keep working. A resume boots from disk instead when no machine with the same CPU type is free (see [snapshots](/docs/snapshots#resume)). To check that path, we rebooted the sandbox: the enabled unit started Gradio, and the hosted URL answered `200` 5.7 seconds after the reboot command. The venv and `app.py` are on the sandbox disk, so nothing needs reinstalling. An idle sandbox pauses itself after `idleStopMinutes` (30 on our test sandbox). Requests to the hosted URL count as activity. See [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use). ## 7. Clean up ```bash sbx delete $id --yes ``` A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. ## FAQ ### Why does my Gradio URL show "Nothing is answering on port 7860"? Gradio binds to `127.0.0.1` unless you tell it otherwise. Launch with `server_name="0.0.0.0"` or `GRADIO_SERVER_NAME=0.0.0.0`, or host the port with `sbx host $id 7860 --localhost`. ### Why does gradio_client say "credentials were not provided"? The URL is private and the client sent neither the token nor the cookie. It drops the `?_token=` query, so fetch the URL once without following redirects, take the `__sbx_auth` cookie from `Set-Cookie`, and pass it as `Client(base, headers={"Cookie": cookie})`. ### Do I need share=True? No. `share=True` creates a separate share link through Gradio's servers. The sandbox's hosted URL already gives you HTTPS, and it is private by default. ### Do file inputs and outputs work through the hosted URL? File outputs did in our test: Gradio returned a `box.sandboxer.com` file URL, and `gradio_client` downloaded the CSV through the proxy. We didn't test file uploads. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Build a stateful code interpreter with a Jupyter kernel](/guides/build-a-stateful-code-interpreter-with-jupyter) - [Run Ollama on CPU in a cloud sandbox](/guides/run-ollama-on-cpu-in-a-cloud-sandbox) - [Hosting](/docs/hosting) --- # How Do I Run a Next.js Dev Server With a Live Preview URL? Source: https://sandboxer.com/guides/run-a-next-js-dev-server-with-a-preview-url. Updated 2026-10-27. **Short answer:** Run next dev on port 3000 in the sandbox and host the port with sbx host. The page loads right away, but hot reload stays broken until you add "*.box.sandboxer.com" to allowedDevOrigins in next.config.ts and restart. Then edits made in the sandbox show up in the browser in under a second. This page is about the Next.js dev server only. For how hosted URLs, tokens and cookies work in general, see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). For built previews of each pull request, see [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request). ## At a glance | Symptom on the hosted URL | Cause | Fix | | - | - | - | | Page loads, but edits never show up until you reload | Next.js blocks its HMR WebSocket from origins it doesn't know | `allowedDevOrigins: ["*.box.sandboxer.com"]`, then restart `next dev` | | Browser console: `WebSocket connection to 'wss://…/_next/hmr?id=…' failed` every few seconds | Same | Same | | Server log: `⚠ Blocked cross-origin request to Next.js dev resource /_next/hmr` | Same | Same | | `502 Nothing is answering on port 3000` | Dev server isn't running | Check the service and its log | | URL works, then stops after the sandbox boots from disk | Dev server was a one-off process | Run it as an enabled systemd unit | Everything below was run on a `small` sandbox (2 vCPU, 4 GB) on 2026-10-27 with Next.js 16.4.3 and Turbopack. We drove a real headless Chromium against the hosted URL to watch the HMR socket. Tokens are replaced with ``. ## 1. Create the app ```bash sbx new --name next-dev --type small id=sb_t0uixywwik # the id sbx new printed sbx exec $id --timeout 600 "npx --yes create-next-app@latest web --yes" ``` ```text Success! Created web at /home/user/web ``` This took 37 seconds, including the npm install. The sandbox has Node 22 and npm preinstalled. `sbx exec` stops a command after 30 seconds by default, so pass `--timeout` for installs. The flags go before the command. ## 2. Allow the hosted origin Next.js [blocks cross-origin requests to dev-only resources](https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins) by default. It allows `localhost` and the hostname it was started with. Your browser's origin is `https://-3000.box.sandboxer.com`, so that is blocked until you list it. Add one line to `next.config.ts`: ```typescript import type { NextConfig } from "next"; const nextConfig: NextConfig = { allowedDevOrigins: ["*.box.sandboxer.com"], // …the rest of your config }; export default nextConfig; ``` Next.js matches only the hostname, and `*` stands for exactly one label. Every hosted port's hostname has one label in front of `box.sandboxer.com` (`lucky-smart-magpie-3000`), so this single entry covers any sandbox and any port. It also covers forks, which get a new subdomain. If you'd rather allow just one URL, the server log prints the exact hostname to paste in (step 5). The config is read only at startup: restart `next dev` after you change it. ## 3. Run `next dev` as a service `next dev` listens on `0.0.0.0` by default ([CLI docs](https://nextjs.org/docs/app/api-reference/cli/next)), so you don't need `--localhost` on the hosted port. Run it as a systemd unit, not a detached command, so it starts again if the sandbox ever boots from disk: ```bash sbx exec $id "sudo tee /etc/systemd/system/next-dev.service >/dev/null < ``` The URL is private: without the token it answered `401`; with it, `302` and a cookie. The first page load after a (re)start compiled the route and took 3.0 seconds. The next load took 0.34 seconds. Open the token link in your browser. The cookie it sets also covers the HMR WebSocket, so there is nothing extra to do for a private URL. Scripts that connect without a browser need to trade the token for the cookie first, as [shown here](/guides/give-a-sandbox-port-an-https-url). ## 5. What happens without `allowedDevOrigins` We ran the same app first without step 2. The page rendered (`200`), so everything looked fine. But the browser's HMR socket never opened: ```text 10:23:24.927 ws open /_next/hmr 10:23:25.156 console error WebSocket connection to 'wss://lucky-smart-magpie-3000.box.sandboxer.com/_next/hmr?id=…' failed: Error during WebSocket … 10:23:25.156 ws close /_next/hmr … (the client retried every 1 to 5 seconds) ``` The server logged why: ```text ⚠ Blocked cross-origin request to Next.js dev resource /_next/hmr from "lucky-smart-magpie-3000.box.sandboxer.com". To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server: ``` We then edited the heading in `app/page.tsx` in the sandbox. Next.js recompiled (`✓ Compiled in 218ms`), but the open page kept the old text for the remaining 20 seconds of the test. Only a manual reload would have shown the change. A dev-asset request that carries an `Origin` header also got `403`, while plain script loads got `200`. That is why the page renders but live updates fail. ## 6. Hot reload over the hosted URL With `allowedDevOrigins` set and the server restarted, we loaded the page and then ran one edit from our laptop: ```bash sbx exec $id "sed -i 's/To get started, edit the/Edit one, from the sandbox:/' web/app/page.tsx" ``` What the browser saw: ```text 10:24:14.361 console log [HMR] connected 10:24:14.362 ws frame turbopack-connected 10:24:14.805 loaded, h1 = "To get started, edit the page.tsx file." 10:24:25.288 (sbx exec returned) 10:24:25.357 ws frame serverComponentChanges 10:24:25.643 ws frame built 10:24:25.664 h1 changed to "Edit one, from the sandbox: page.tsx file." ``` The heading changed 0.38 seconds after the edit command returned, with no reload from us. This loop is what you want when a coding agent edits files in the sandbox and you watch the result in your browser. ## Stop, resume and memory | Check | Result | | - | - | | `sbx stop $id --wait` | 2.2 s; the URL answered `404` while stopped | | `sbx resume $id` | 1.3 s, `memoryRestored: true` | | First request after resume, old cookie | `200` in 0.26 s: the same dev server, already warm | | HMR after resume | `[HMR] connected`; the next edit showed up live | | Memory on `small` | `next-server` used about 435 MB; the sandbox used 768 MB of 3,914 MB | Routes, tokens and browser cookies survive a stop. If a resume can't restore memory (`memoryRestored: false`, for example on a different CPU type), the sandbox boots from disk. The enabled unit then starts `next dev` again and the first page load compiles once more. We did not hit that case in this test. ## FAQ ### Do I need `--localhost` when I host the port? No. `next dev` binds `0.0.0.0` by default and logs a `Network:` address. Use `--localhost` only if you started it with `-H 127.0.0.1`. ### Can I put the full URL in `allowedDevOrigins`? No. Next.js matches only the hostname, so leave out `https://`, the port and the `?_token=`. Never commit a token to `next.config.ts`. ### Does this affect `next build` and `next start`? No. `allowedDevOrigins` applies only in development. A production server has no HMR socket. For built previews, see [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request). ### Why is the first page load slow? `next dev` compiles each route on its first request. In our test that took 3.0 seconds on a `small` sandbox, and later loads took about 0.3 seconds. After a restart, or a resume that boots from disk, the first load compiles again. ### Can I share the preview with a teammate? Yes. Send the private token link. It works in their browser, HMR included, because the cookie covers the WebSocket. Switching the port to `--public` and back mints a new token, which cuts off old links. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request) - [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use) - [Hosting](/docs/hosting) --- # How to Run Storybook in a Sandbox With a Shareable URL Source: https://sandboxer.com/guides/run-storybook-with-a-shareable-url. Updated 2026-10-27. **Short answer:** Run storybook dev on port 6006 in a sandbox, add .box.sandboxer.com to core.allowedHosts, set Vite's hmr.clientPort to 443, then run sbx host 6006 --localhost. Keep that dev URL private. For a link anyone can open, run storybook build, serve storybook-static and host that port with --public. ## What we ran Everything below ran on prod on 2026-10-27, on one `small` sandbox (2 vCPU, 4 GB), with Storybook 10.6.4, Vite 8.3.5 and React 19 on the sandbox's Node.js 22. Tokens are replaced with ``. For how hosted URLs, tokens and cookies work in general, see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). This page covers what Storybook needs on top of that. | Step | Result | | - | - | | Vite scaffold and `npm install` | 10.3 s | | `storybook init` | 25.7 s | | Dev server start (systemd restart to first answer) | 1.6 to 2.4 s | | Dev URL, default config | `502`, then `403 Invalid host` | | Dev URL with `allowedHosts` | `200`, 11 stories in `index.json`, about 0.19 s per request | | Hot reload through the URL | Broken until `hmr.clientPort: 443`; then the edit showed in 176 ms, no page reload | | `storybook build` | 3.6 s, 8.5 MB, 63 files | | Static build on a public URL | `200`, Button story drawn in 4.5 s in headless Chromium | | After `sbx stop --wait` and `sbx resume` | `memoryRestored: true`, same PIDs, both URLs answered at once | ## Dev server or static build? | | Dev server (`storybook dev`) | Static build (`storybook build`) | | - | - | - | | Who it's for | You, or a reviewer pairing with you | Designers, PMs, anyone with the link | | Hot reload | Yes, over WebSockets | No, rebuild to update | | What a visitor can do | Storybook warns that the dev server lets clients write files in your project through its WebSocket channel | Read the built HTML, JS and CSS | | Hosted access | Private only | Public is fine if the components aren't secret | | Server | Node.js, about 2 s to start | Any static file server | ## 1. Install Storybook Storybook's [install docs](https://storybook.js.org/docs/get-started/install) add it to an existing project. We made a Vite React app first, then ran the initializer without prompts: ```bash sbx new --name storybook --type small sbx exec sb_dt3tuszvq5 --timeout 600 ' npm create vite@latest sb-demo -- --template react-ts --no-interactive cd sb-demo && npm install npx -y storybook@latest init --yes --no-dev --features docs --disable-telemetry --no-agent' ``` ```text added 28 packages in 9s … ◇ Storybook was successfully installed in your project! ``` `--no-dev` stops `init` from starting a server inside your `exec`. `--features docs` skips the test addon, which would also pull in Vitest and browser binaries. The default `--timeout` of `sbx exec` is 30 s, and `init` took 25.7 s, so raise it. ## 2. Run the dev server as a service `npm run storybook` runs `storybook dev -p 6006`, which listens on `127.0.0.1` only. A systemd unit keeps it running and starts it again after a cold boot. Run this inside the sandbox (over `sbx ssh`, or save it as a script and pass it to `sbx exec`). It writes the unit with your user, home and npm path filled in: ```bash NPM=$(command -v npm) sudo tee /etc/systemd/system/storybook-dev.service >/dev/null < ``` Without the token, the URL returns `401`. With it, you get the 30-day cookie and then `502` with the page "Nothing is answering". Host the same port again with `--localhost` so the sandbox relays to the loopback address. The URL and token stay the same: ```bash sbx host sb_dt3tuszvq5 6006 --localhost --title "Storybook dev" ``` Now Storybook itself answers, and refuses the request: ```text / with cookie: 403 Invalid host /index.json with cookie: 403 Invalid host ``` Storybook checks the `Host` header, and `direct-noble-squid-6006.box.sandboxer.com` isn't on its list. Add the hosted domain to [`core.allowedHosts`](https://storybook.js.org/docs/api/main-config/main-config-core#allowedhosts). A leading dot allows every subdomain, so the same file works in any sandbox: ```typescript // .storybook/main.ts import type { StorybookConfig } from '@storybook/react-vite'; import { mergeConfig } from 'vite'; const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: ['@storybook/addon-docs'], framework: '@storybook/react-vite', // Accept requests for the hosted URL (*.box.sandboxer.com). core: { allowedHosts: ['.box.sandboxer.com'] }, // The browser reaches the sandbox on 443, not on 6006. viteFinal: (config) => mergeConfig(config, { server: { hmr: { clientPort: 443 } } }), }; export default config; ``` After `sudo systemctl restart storybook-dev` (1.6 s to the first answer): ```text / with cookie: 200 in 0.195 s /index.json with cookie: 200 in 0.191 s 11 entries: configure-your-project--docs, example-button--docs, example-button--large, example-button--primary, … ``` A request without the token still gets `401` from the proxy, so Storybook never sees it. Storybook offers another route: `--host 0.0.0.0` and a plain `sbx host`. We tried that only on a local machine, not on prod. There, Storybook 10.6.4 printed a warning that it was listening on all interfaces, that anyone who can reach it can read your stories and source files and write files in your project through the WebSocket channel and the MCP server, and to set `core.allowedHosts` to keep host validation on. The `--localhost` route above keeps validation on. ## 4. Fix hot reload behind the URL `allowedHosts` alone gets you a working Storybook, but edits don't show up. We opened the token link in headless Chromium (Playwright), edited `Button.tsx` in the sandbox, and waited for the change. It never came. The browser log showed why: ```text [vite] connecting... WebSocket connection to 'wss://direct-noble-squid-6006.box.sandboxer.com:6006/?token=' failed: net::ERR_CONNECTION_REFUSED [vite] failed to connect to websocket (Error: WebSocket closed without opened.). ``` Vite's client in the preview iframe connects to the dev server's port, 6006, on the hosted name. The hosted URL only serves 443. Storybook's own channel (`/storybook-server-channel`) used 443 and connected. The `viteFinal` line in the file above sets Vite's [`server.hmr.clientPort`](https://vite.dev/config/server-options#server-hmr) to 443. With it, the same test passed: ```text first response: 200 [vite] connecting... [vite] connected. Primary story rendered after 4113 ms, button text: Button [vite] hot updated: /src/stories/Button.tsx edited Button.tsx in the sandbox: new class showed up after 176 ms page reloaded: false ``` The browser had only the cookie from the token link. The WebSocket handshakes carried that cookie, so hot reload works on a private URL without extra steps. ## 5. Publish a static build on a public URL For a link you can paste in a PR or a chat, build Storybook and serve the files. The [publishing docs](https://storybook.js.org/docs/sharing/publish-storybook) describe the build; any static server works, and Python is already in the sandbox: ```bash sbx exec sb_dt3tuszvq5 --timeout 600 "cd ~/sb-demo && npm run build-storybook && du -sh storybook-static" ``` ```text ◇ Output directory: /home/user/sb-demo/storybook-static 8.5M storybook-static ``` The build took 3.6 s. A second unit serves it on port 6007, bound to all interfaces: ```ini # /etc/systemd/system/storybook-static.service [Unit] Description=Static Storybook After=network-online.target [Service] User=user ExecStart=/usr/bin/python3 -m http.server 6007 --directory /home/user/sb-demo/storybook-static Restart=always [Install] WantedBy=multi-user.target ``` ```bash sbx exec sb_dt3tuszvq5 "sudo systemctl daemon-reload && sudo systemctl enable --now storybook-static" sbx host sb_dt3tuszvq5 6007 --public --title "Storybook static" ``` ```text /: 200 in 0.197 s /index.json: 200 in 0.192 s /iframe.html?id=example-button--primary&viewMode=story: 200 in 0.291 s /storybook-server-channel: 404 in 0.192 s ``` There is no write channel to reach: `/storybook-server-channel` is a plain `404`. Headless Chromium drew the Button story from `https://direct-noble-squid-6007.box.sandboxer.com/?path=/story/example-button--primary` in 4.5 s, with no WebSockets opened. To update the link, run `npm run build-storybook` again. The server picks up the new files without a restart. ## 6. Stop and resume ```bash sbx stop sb_dt3tuszvq5 --wait sbx resume sb_dt3tuszvq5 ``` While stopped, both URLs returned `404`. The resume took 1.3 s and reported `"memoryRestored": true`. Both servers kept their PIDs, the public URL answered `200` on the first try, and the dev URL answered `200` with the cookie set before the stop. Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk: files, hosted routes and tokens are kept, and processes start fresh. Both units are `enabled`, so systemd starts them at boot. We didn't force a cold boot on this sandbox; [Run code-server in the browser](/guides/run-code-server-vs-code-in-the-browser) shows the same systemd pattern surviving one, tested with a fork. ## FAQ ### Why does my hosted Storybook say "Invalid host"? Storybook rejects `Host` headers it doesn't know. Add `'.box.sandboxer.com'` to `core.allowedHosts` in `.storybook/main.ts` and restart the dev server. ### Storybook loads but my edits don't appear. Why? Vite's hot reload client tries to connect on port 6006, which the hosted URL doesn't serve. Set `server.hmr.clientPort` to 443 in `viteFinal`. The browser console shows `[vite] connected.` when it works. ### Can I make the dev server URL public? Don't. Storybook's own warning says clients of the dev server can read source files and write files in your project. Keep the dev port private and share a static build with `--public` instead. ### Do reviewers need a Sandboxer account to open the private link? No. Anyone with the full link gets a 30-day cookie and can use Storybook, hot reload included. Treat the link like a password. Switching the port to public and back to private makes a new token and cuts off the old link. ## Related - [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) - [Get a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request) - [Run code-server (VS Code in the browser)](/guides/run-code-server-vs-code-in-the-browser) - [Hosting](/docs/hosting) --- # How Do I Preview a Hugo or Astro Site on an HTTPS URL in a Sandbox? Source: https://sandboxer.com/guides/preview-a-hugo-or-astro-site-in-a-sandbox. Updated 2026-10-27. **Short answer:** Run hugo server with --baseURL set to the hosted URL, --appendPort=false and --liveReloadPort 443, or run astro dev with server.allowedHosts set to .box.sandboxer.com, then sbx host the port. Live reload works through the private URL. A 300-page build took about 0.15 s in Hugo and 4.2 s in Astro. How to host a port is covered in [give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url), and per-PR previews in [a preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request). This page covers what Hugo and Astro need on top of that. Their dev servers assume they're on `localhost`, so behind an HTTPS proxy they break in ways that don't show up on your laptop. ## What we ran Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, $0.018/hour). Times are from one sandbox. Build times are three runs. Tokens are replaced with ``. | Step | Result | | - | - | | `sbx new --type small` | 3.2 s to ready | | Install Hugo v0.168.0 extended from the GitHub `.deb` | worked (not timed) | | `hugo --minify`, 338 pages | 150, 175, 152 ms (Hugo's own "Total in") | | `npm create astro@latest` (minimal template, with install) | 26.3 s | | Upgrade Node.js 22 to 24 for Astro 7.4.2 | 13 s | | `astro build`, 301 pages | 4.22 s reported, 4.6–5.5 s wall clock with `npx` | | Edit a file to the reload message on the laptop, through the private URL | Hugo 357 ms, Astro 227 ms (each includes a ~200 ms `sbx exec` round trip) | | `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, both URLs `200` | ## Hugo We installed the extended edition from the `.deb` on Hugo's [GitHub releases](https://github.com/gohugoio/hugo/releases), one of the methods in its [Linux install docs](https://gohugo.io/installation/linux/). Ubuntu's `apt` package is usually older. We saved these lines as `hugo-setup.sh` in the sandbox (`sbx new --name blog --type small` gave us `sb_5698hpwhgl`, so the commands below use `id=sb_5698hpwhgl`) and ran it with `sbx exec $id --timeout 600 "bash hugo-setup.sh"`: ```bash set -e V=$(curl -fsSL https://api.github.com/repos/gohugoio/hugo/releases/latest | jq -r .tag_name | sed 's/^v//') curl -fsSLo /tmp/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${V}/hugo_extended_${V}_linux-amd64.deb sudo dpkg -i /tmp/hugo.deb >/dev/null hugo version hugo new site blog >/dev/null && cd blog && hugo new theme demo >/dev/null && echo "theme = 'demo'" >> hugo.toml ``` ```text hugo v0.168.0-e27a6760c5547237f87d353aebe0580feadb20f4+extended linux/amd64 BuildDate=2026-10-13T16:42:50Z VendorInfo=gohugoio ``` ### What goes wrong with the defaults We first started `hugo server --bind 0.0.0.0 --port 1313` and hosted port 1313. The page loaded through the private URL. Two things were broken: ```bash curl -s -b jar https://ample-vital-ember-1313.box.sandboxer.com/ | grep -o ']*livereload[^>]*>' curl -s -b jar https://ample-vital-ember-1313.box.sandboxer.com/index.xml | grep -o '[^<]*' | head -2 ``` ```text ``` To test it from a different origin, we served a page with that snippet on a third, public port and loaded it in headless Google Chrome, which is preinstalled in the sandbox. Then we read the counts through the dashboard URL with the [website stats API](https://docs.umami.is/docs/api/website-stats): ```bash W=25ed7865-18fd-4d73-9813-751a024a294c # the website id END=$(date +%s)000; START=$((END - 3600000)) # the last hour, in ms google-chrome --headless=new --no-sandbox --virtual-time-budget=8000 --dump-dom \ --user-agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" \ "https://icy-neat-delta-8090.box.sandboxer.com/?run=real" curl -s -b jar -H "Authorization: Bearer $JWT" \ "$B/api/websites/$W/metrics?type=hostname&startAt=$START&endAt=$END" ``` ```text [{"x":"demo.example.com","y":2},{"x":"icy-neat-delta-8090.box.sandboxer.com","y":1}] ``` Two things we learned here: - **Bots are dropped quietly.** Headless Chrome with its own user agent (`HeadlessChrome`) loaded the page, and the pageview count didn't move. With a normal user agent it went from 3 to 4. `curl`'s default user agent gets `200 {"beep":"boop"}` and nothing is stored. Test with a browser user agent, or set `DISABLE_BOT_CHECK` while you test. - **CORS is already open.** A preflight from `Origin: https://demo.example.com` got `204` with `access-control-allow-origin: *`, so any site can post to the collector. ## 7. Countries come through the proxy Umami works out a visitor's country from their IP address. Behind two proxies, the sandbox's and Caddy's, that is easy to lose. It wasn't. Our laptop's events showed `US`, and the page Chrome loaded from inside the sandbox showed `FI`, the sandbox's own exit address: ```text [{"x":"US","y":2},{"x":"FI","y":1}] ``` The Sandboxer proxy sends both `X-Forwarded-For` and `X-Real-Ip` with the client's address. We checked with a small header-echo server on a hosted port. Caddy rewrites `X-Forwarded-For` unless you list [trusted proxies](https://caddyserver.com/docs/caddyfile/options#trusted-proxies), but it passes `X-Real-Ip` on unchanged. Umami got the right country with and without a `trusted_proxies` setting. If you put a different proxy in front, check this again; `CLIENT_IP_HEADER` tells Umami which header to read. ## 8. Restarts, stop and resume **Docker restart.** `sudo systemctl restart docker` brought all three containers back by themselves, and both URLs answered after 13.3 s. All 64 pageviews were still there, and the bearer token from before still worked, because `APP_SECRET` comes from `.env` and doesn't change. **Stop and resume.** `sbx stop $id --wait` took 2.2 s and `sbx resume $id` 1.3 s, with `memoryRestored: true`. The containers kept running, and the dashboard and collector both answered right away. **While it's stopped, visits are lost.** We stopped the sandbox and posted an event to the collector. On October 27, 2026 it got `404` with the page "No sandbox uses this address." Nothing queues those events, so they never reach Umami. A collector has to be running to count anything: - New sandboxes stop after 1 hour by default. Create a collector with `sbx new --no-auto-stop`, or set a longer `--ttl` ([auto-stop](/docs/long-running#auto-stop)). - Requests to hosted URLs count as activity, so steady traffic keeps idle auto-pause away. After a quiet spell, the next tracker request wakes the sandbox and is held while it resumes ([idle auto-pause](/docs/long-running#idle-auto-pause-and-wake-on-use)). We didn't test a wake from the tracker. **Boot from disk.** A resume boots from disk when you resize or no machine with the same CPU type is free. We tested it with `sbx resume $id --type default`: ```text type default memoryRestored False resumeReason resized 2.45 4.82 # /proc/uptime: a fresh boot ready after 12 s more collector Up 12 seconds db Up 12 seconds (healthy) umami Up 12 seconds (health: starting) ``` Docker started at boot and `restart: unless-stopped` brought the stack back. The dashboard, both URLs and every stored pageview came back with it. ## 9. Clean up ```bash sbx delete $id --yes ``` After the delete, `sbx info` returned `not_found` and both URLs returned 404. Export anything you want to keep first, for example with `docker compose exec -T db pg_dump -U umami umami > umami.sql` and `sbx scp`. We didn't run that step. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. ## FAQ ### Can I just make the Umami port public? Yes, `sbx host $id 3000 --localhost --public` works, and Umami's own login still protects the dashboard. But the login page and the whole API are then open to anyone. The collector keeps those private and exposes only `/script.js` and `/api/send`. ### Why are my test pageviews missing? Umami drops requests whose user agent looks like a bot, and it still answers `200`. `curl` and headless Chrome are both treated as bots. Send a normal browser user agent, or set `DISABLE_BOT_CHECK=1` while you test. ### Is `small` enough? For a few sites, yes. Umami used 143 MB and Postgres 34 MB of the 4 GB, and an event took 11 ms inside the sandbox. We didn't load-test it. ### Will ad blockers block the collector? Some block `script.js` and `/api/send` by name. Umami's `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` rename them. Add the new names to the Caddyfile's `path` line too. We didn't test the renamed paths. ### Where does the data live? In the `umami-db-data` Docker volume on the sandbox disk. It survives restarts, stop, resume and a boot from disk. It doesn't survive `sbx delete`. ## Related - [Run Uptime Kuma in a sandbox](/guides/run-uptime-kuma-in-a-sandbox) - [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) - [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use) - [Hosting](/docs/hosting) --- # How to Run Strapi in a Cloud Sandbox With a Private HTTPS URL Source: https://sandboxer.com/guides/run-strapi-in-a-sandbox. Updated 2026-10-27. **Short answer:** Create a Strapi 5 app with create-strapi in a small Sandboxer sandbox, build the admin panel and run strapi start under systemd on 127.0.0.1:1337. Host it with sbx host --localhost. Set proxy.koa so login cookies are Secure. Clients send the __sbx_auth cookie plus a Strapi API token. [Strapi](https://docs.strapi.io/cms/intro) is a Node.js headless CMS: an admin panel where editors fill in content, and a REST API that serves it. This page covers what is specific to Strapi behind a sandbox's hosted URL: a database path bug in the generated `.env`, the proxy setting that makes login cookies `Secure`, and the split between `develop` (schema changes) and `start` (production). For a generic Node setup, see the [Node.js dev environment](/guides/node-dev-environment-in-a-cloud-sandbox). ## What we ran Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB RAM, 12 GB disk, region `eu-central`) with Strapi 5.59.0 on the sandbox's Node 22.23.3, SQLite and the JavaScript template. Tokens and passwords are replaced with placeholders. Times are single runs unless the table says otherwise. | Step | Result | | - | - | | `create-strapi` with `npm install` | about 2 minutes, 1,398 packages, 814 MB `node_modules` | | `npm run build` (admin panel, production) | 35.1 s; memory in use peaked at 2.6 GB of 4 GB | | `systemctl restart strapi` to a passing `/_health` | 3.2 s | | Memory of `strapi start` | 240 MB resident | | List articles through the hosted URL, from our test machine | median 195 ms over 20 calls (192 to 198 ms) | | Same call inside the sandbox on `127.0.0.1:1337` | median 7.7 ms over 20 calls | | `sbx stop --wait`, then `sbx resume` | `memoryRestored: true`, same PID, admin session still valid | | Stop, then resume with `--type default` (boots from disk) | `/_health` answered through the URL 1.0 s after resume returned | The build is the step that needs the RAM. `small` handled it, but nothing else heavy was running. ## 1. Create the app `create-strapi` is Strapi's [own installer](https://docs.strapi.io/cms/installation/cli). These flags skip every prompt and the Strapi Cloud login: ```bash sbx new --name strapi --type small id=sb_cvah5xd2lv # the id that sbx new printed; its subdomain was smooth-vital-sierra sbx exec $id --detach "npx -y create-strapi@5.59.0 cms --non-interactive --skip-cloud --js --use-npm \ --install --no-example --no-git-init --dbclient=sqlite --no-run > create.log 2>&1" ``` The install takes about two minutes, longer than the 30-second default of `sbx exec`, so it runs detached. Poll `tail create.log` until it prints: ```text added 1398 packages, and audited 1399 packages in 2m … Strapi Your application was created! ``` ### Fix the empty database path first With these flags, the generated `.env` had an empty `DATABASE_FILENAME=`. Strapi's `config/database.js` falls back to `.tmp/data.db` only when the variable is missing, not when it is empty. So the SQLite path became the project folder itself, and the server crash-looped: ```text warn: [database] knex: Acquire connection error: SqliteError: unable to open database file error: unable to open database file info: Shutting down Strapi ``` We printed the resolved path to confirm it: ```bash sbx exec $id "cd cms && node -e \"process.env.DATABASE_FILENAME='';const c=require('./config/database.js')({env:require('@strapi/utils').env});console.log(c.connection.connection.filename)\"" ``` ```text /home/user/cms ``` Set the path explicitly. While you are in `.env`, bind Strapi to `127.0.0.1`. The sandbox has a public IPv6 address, and the hosted URL can reach a localhost-only app through `--localhost`: ```bash sbx exec $id "cd cms && sed -i 's|^DATABASE_FILENAME=\$|DATABASE_FILENAME=.tmp/data.db|' .env" sbx exec $id "cd cms && sed -i 's/^HOST=0.0.0.0/HOST=127.0.0.1/' .env" ``` ## 2. Add a content type, then build Content types are files under `src/api`. We added an `article` type with a title, body and cover image. These are the same files the admin's Content-Type Builder writes: ```json { "kind": "collectionType", "collectionName": "articles", "info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" }, "options": { "draftAndPublish": true }, "attributes": { "title": { "type": "string", "required": true }, "body": { "type": "text" }, "cover": { "type": "media", "multiple": false, "allowedTypes": ["images"] } } } ``` That file goes in `src/api/article/content-types/article/schema.json`. Then add a one-line controller, router and service, each in its own folder: ```js // src/api/article/controllers/article.js (routes/article.js and services/article.js use createCoreRouter and createCoreService) module.exports = require('@strapi/strapi').factories.createCoreController('api::article.article'); ``` Build the admin panel for production: ```bash sbx exec $id --timeout 300 "cd cms && NODE_ENV=production npm run build" ``` ```text ✔ Building build context (89ms) ✔ Building admin panel (35062ms) ``` ## 3. Run it with systemd and create the admin ```ini # strapi.service [Unit] Description=Strapi After=network.target [Service] Type=simple User=user WorkingDirectory=/home/user/cms Environment=NODE_ENV=production ExecStart=/usr/bin/node node_modules/.bin/strapi start Restart=always RestartSec=5 [Install] WantedBy=multi-user.target ``` ```bash sbx scp strapi.service $id:/home/user/ sbx exec $id "sudo cp strapi.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now strapi" ``` Create the first admin with the [CLI](https://docs.strapi.io/cms/cli), so nobody can claim the admin panel by opening it first. The password comes from a file and is never printed. Strapi wants at least 8 characters with an uppercase letter, a lowercase letter and a digit: ```bash sbx exec $id "umask 077; echo \"Sb\$(openssl rand -hex 12)\" > ~/admin-pass; cd cms && NODE_ENV=production \ npx strapi admin:create-user --firstname=Ada --lastname=Admin --email=admin@example.com --password=\"\$(cat ~/admin-pass)\"" ``` ```text Successfully created new admin ``` Check the service with `journalctl -u strapi`. That is where the database error in step 1 showed up. ## 4. Host it and set the two proxy settings ```bash sbx host $id 1337 --localhost --title strapi ``` ```text {"ok":true,"type":"port.hosted","sandboxId":"sb_cvah5xd2lv","port":1337,"url":"https://smooth-vital-sierra-1337.box.sandboxer.com?_token=","access":"private","isProtected":true,"localhost":true} ``` The admin panel is at `/admin` and the API at `/api` on the same host. TLS ends at the Sandboxer edge, which sends Strapi plain HTTP with `X-Forwarded-Proto: https`. Strapi's [server config](https://docs.strapi.io/cms/configurations/server) has two settings for this: `url`, the public address, and `proxy.koa`, which tells Strapi to trust the forwarded headers. We put both behind environment variables in `config/server.js`: ```js module.exports = ({ env }) => ({ host: env('HOST', '0.0.0.0'), port: env.int('PORT', 1337), url: env('PUBLIC_URL', ''), proxy: { koa: env.bool('IS_PROXIED', false) }, app: { keys: env.array('APP_KEYS') }, webhooks: { populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false) }, }); ``` ```bash sbx exec $id "cd cms && echo PUBLIC_URL=https://smooth-vital-sierra-1337.box.sandboxer.com >> .env \ && echo IS_PROXIED=true >> .env && sudo systemctl restart strapi" ``` We logged in to the admin API from our test machine before and after. Without `proxy.koa`, Strapi thought the request was plain HTTP and left `Secure` off its refresh cookie, even though the browser was on HTTPS: ```text set-cookie: strapi_admin_refresh=; path=/admin; expires=Tue, 10 Nov 2026 11:45:19 GMT; samesite=lax; httponly ``` With `IS_PROXIED=true`: ```text set-cookie: strapi_admin_refresh=; path=/admin; expires=Tue, 10 Nov 2026 11:47:01 GMT; samesite=lax; secure; httponly ``` Login worked both ways, so the only symptom of the missing setting is the weaker cookie. `url` turned out to be less fragile than in apps that store their own address. In Strapi 5.59 the admin bundle calls the API with a relative path when both are on the same origin: the built JavaScript contained `STRAPI_ADMIN_BACKEND_URL:"/"`. We built once with `PUBLIC_URL=http://localhost:1337`, and the admin still loaded and logged in through the hosted URL. You don't need to rebuild if the hosted URL changes. Set `url` anyway: Strapi's docs say it builds absolute links from it, for example in password reset emails, and the startup log then prints the right admin address. ## 5. Two gates: the proxy and Strapi From our test machine, `$B` is the base URL and `$T` the URL's token: ```bash curl -s -o /dev/null -w "no token: %{http_code}\n" $B/admin curl -s -c jar -o /dev/null -w "token: %{http_code} -> %{redirect_url}\n" "$B/admin?_token=$T" curl -s -b jar -o /dev/null -w "admin with cookie: %{http_code}\n" $B/admin curl -s -b jar -w "\n%{http_code}\n" $B/api/articles ``` ```text no token: 401 token: 302 -> https://smooth-vital-sierra-1337.box.sandboxer.com/admin admin with cookie: 200 {"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}} 403 ``` The `401` comes from the Sandboxer proxy, and Strapi never sees the request. The `403` is Strapi's: new content types aren't public until you give the Public role permission, or the client sends an [API token](https://docs.strapi.io/cms/features/api-tokens). In a browser, open the full `?_token=` link once and the cookie does the rest. ## 6. Get an API token and write content from your laptop You can create an API token in the admin panel under Settings, API Tokens. We did the same with two calls through the hosted URL. `login.json` holds the email and password: ```bash curl -s -b jar -H 'Content-Type: application/json' -d @login.json $B/admin/login > login.out AJ=$(python3 -c "import json; print(json.load(open('login.out'))['data']['token'])") curl -s -b jar -H "Authorization: Bearer $AJ" -H 'Content-Type: application/json' \ -d '{"name":"laptop","type":"full-access","lifespan":null}' $B/admin/api-tokens > token.out K=$(python3 -c "import json; print(json.load(open('token.out'))['data']['accessKey'])") ``` Every request needs both gates: the `__sbx_auth` cookie for the proxy and `Authorization: Bearer` for Strapi. The proxy passes `Authorization` through untouched. Then use the [REST API](https://docs.strapi.io/cms/api/rest). A plain create publishes. `?status=draft` keeps a draft ([status parameter](https://docs.strapi.io/cms/api/rest/status)): ```bash curl -s -b jar -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \ -d '{"data":{"title":"Hello from a sandbox","body":"Written over the hosted URL."}}' $B/api/articles curl -s -b jar -H "Authorization: Bearer $K" -H 'Content-Type: application/json' \ -d '{"data":{"title":"Draft only"}}' "$B/api/articles?status=draft" curl -sg -b jar -H "Authorization: Bearer $K" "$B/api/articles?fields[0]=title" curl -s -H "Authorization: Bearer $K" -o /dev/null -w "token but no cookie: %{http_code}\n" $B/api/articles ``` ```text {"data":{"id":2,"documentId":"si6c6sg4vheslttv5nmz30qc","title":"Hello from a sandbox",…,"publishedAt":"2026-10-27T11:47:17.730Z"},"meta":{}} {"data":{"id":3,"documentId":"sb1reucadu3wx1prjb0n94sn","title":"Draft only",…,"publishedAt":null},"meta":{}} {"data":[{"id":2,"documentId":"si6c6sg4vheslttv5nmz30qc","title":"Hello from a sandbox"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":1}}} token but no cookie: 401 ``` Use `curl -g` for Strapi's bracket query strings. Without it, curl reads `fields[0]` as a URL range and sends nothing. ### Media files sit behind the same gate Uploads go to `/api/upload` ([upload API](https://docs.strapi.io/cms/api/rest/upload)) and come back with a relative URL: ```bash curl -s -b jar -H "Authorization: Bearer $K" -F files=@cover.png $B/api/upload curl -s -b jar -H "Authorization: Bearer $K" -X PUT -H 'Content-Type: application/json' \ -d '{"data":{"cover":1}}' $B/api/articles/si6c6sg4vheslttv5nmz30qc curl -s -o /dev/null -w "no cookie: %{http_code}\n" $B/uploads/cover_ff4c8ce39e.png curl -s -b jar -o /dev/null -w "with cookie: %{http_code} %{content_type}\n" $B/uploads/cover_ff4c8ce39e.png ``` ```text [{"id":1,"name":"cover.png",…,"url":"/uploads/cover_ff4c8ce39e.png",…}] no cookie: 401 with cookie: 200 image/png ``` A frontend has to prefix the base URL, and a browser that never opened the token link gets `401` for the image. Media files don't need Strapi's API token, only the proxy cookie. Over 20 list calls with the cover populated, the median was 195 ms from our test machine and 7.7 ms inside the sandbox. The difference is the network path to `eu-central`, not Strapi. Code that runs in the sandbox should call `http://127.0.0.1:1337`. ## 7. Schema changes need `develop`, not `start` In production mode, the Content-Type Builder refuses changes. The admin API answered: ```text {"data":null,"error":{"status":403,"name":"PolicyError","message":"Content-Type Builder modifications are disabled in production mode. Schema changes can only be made when running with autoReload enabled (strapi develop).","details":{}}} ``` To edit schemas in the browser, stop the service and run `npm run develop` instead, through the same hosted URL. We did, and it worked: - `/admin` loaded from Vite in development mode (`/admin/@vite/client`). - Vite's hot-reload WebSocket upgraded through the proxy: `101 Switching Protocols` on `wss://…-1337.box.sandboxer.com/admin/` with the `vite-hmr` subprotocol. - Creating a `note` type through the builder API returned `201`, wrote `src/api/note`, and Strapi reloaded and answered again 4.1 s later. - Memory in use was 0.9 GB, against 0.6 GB under `strapi start`. Then stop `develop`, run `npm run build` again and start the service. The `note` type was there, and the API token from step 6 could write to `/api/notes` straight away. The schema lives in files, so commit `src/api` if you keep the project in git. While nothing listened on 1337, between stopping `develop` and starting the service, the URL returned a `502` page from Sandboxer titled "Nothing is answering". Here it meant our app was between a stop and a start; it went away once Strapi was listening again. ## 8. Stop, resume and a boot from disk ```bash sbx stop $id --wait sbx resume $id sbx info $id # "memoryRestored": true sbx exec $id "pgrep -f 'strapi start'; systemctl show -p NRestarts strapi" ``` ```text 4906 … NRestarts=0 ``` PID 4906 was the same process as before the stop, and systemd counted no restarts. Both articles were there, and the admin refresh cookie from before the stop still got a new access token (`POST /admin/access-token` returned `200`). A resume boots from disk instead when you resize or no machine with the same CPU type is free. We forced that with `sbx stop $id --wait` and `sbx resume $id --type default`, then polled `/_health` through the hosted URL until it returned `204`. Our timing script printed: ```text stop --wait 2.2 s, resume 3.4 s, resume->health 204 via URL 1.0 s ``` `sbx info` showed `"memoryRestored": false` and `"resumeReason": "resized"`. Uptime was 8.7 s when we checked, so this was a fresh boot. The `enabled` unit started Strapi again, and the articles, the cover image under `public/uploads` and the admin session all survived. They live in `.tmp/data.db` and on disk. Moving to a smaller size isn't supported yet, so a resize to `default` stays at `default` ($0.036/hour instead of $0.018). ## 9. Clean up Copy out what you want to keep first: `.tmp/data.db`, `public/uploads` and `src`, with `sbx scp`. Then: ```bash sbx delete $id --yes ``` After the delete, `sbx info` returned `not_found` and the URL returned `404`. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped. ## FAQ ### Why does Strapi say "unable to open database file" on first start? Check `DATABASE_FILENAME` in `.env`. Our non-interactive `create-strapi` run left it empty, and an empty value makes Strapi open the project folder as the database. Set it to `.tmp/data.db`. ### Do I need to rebuild the admin panel when the hosted URL changes? No, in our test with Strapi 5.59. When the admin and API share an origin, the build uses a relative API path, so the bundle doesn't contain the host. Restart after changing `PUBLIC_URL` so links and the startup log use it. ### Is `small` big enough for Strapi? For one developer and a SQLite database, yes. The admin build is the peak: memory in use reached 2.6 GB of 4 GB. The server used about 240 MB and `develop` about 0.9 GB total. If a build runs out of memory, the process is killed with exit code 137 and the sandbox stays up. Create the sandbox as `default` then. ### Can I make the API public for a frontend? `sbx host $id 1337 --localhost --public` drops the proxy's cookie check, so the admin login page and anything you opened to Strapi's Public role are reachable by anyone with the URL. Keep a strong admin password. Switching from public back to private mints a new token, so every client needs the new cookie. ### Should I use Postgres instead of SQLite? For a test or preview instance, SQLite keeps everything in one file and needed no extra services here. Strapi also supports Postgres and MySQL ([database config](https://docs.strapi.io/cms/configurations/database)). We tested only SQLite. ## Related - [Run PocketBase in a sandbox](/guides/run-pocketbase-in-a-sandbox) - [Run WordPress in a sandbox](/guides/run-wordpress-in-a-sandbox) - [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume) - [Hosting](/docs/hosting) --- # Retry Sandbox API Calls Safely With Idempotency Keys Source: https://sandboxer.com/guides/retry-sandbox-api-calls-safely-with-idempotency-keys. Updated 2026-10-27. **Short answer:** Send an Idempotency-Key header on POST /sandboxes and on fork, and reuse it on every retry. For 24 hours, the same key and body return the same sandbox instead of a second billable one. Retry 429, 5xx and network errors with backoff. Don't retry other 4xx errors, because the request itself has to change. An agent loop fails in an awkward way. A create request times out, but the server may already have started the VM. If you resend without a key, you can end up with two running sandboxes and pay for both. An idempotency key lets you resend without that risk. ## What the key covers | Call | Takes `Idempotency-Key` | A replay returns | | - | - | - | | `POST /sandboxes` (create) | Yes | `202` with the same `sandbox.id` | | `POST /sandboxes/{id}/fork` | Yes, scoped to that source sandbox | `202` with the same fork's id | | Everything else (commands, files, stop, delete) | No | Not applicable | How the server treats a key: - **Lifetime:** 24 hours from first use. - **Scope:** your account and the endpoint. A key used on create is separate from the same key on a fork, and each source sandbox's forks have their own scope. - **Body match:** the server compares the JSON body with key order and whitespace ignored. If the same key comes with a different body, you get `409 idempotency_key_reused`. - **Still running:** a replay that arrives while the first request is still being processed gets `409 idempotency_in_progress`. Retry with the same key. - **Failed first try:** if the first request fails, the server releases the key, so a retry with the same key tries the create again. - **Length:** at most 255 characters (`400 invalid_idempotency_key`). A UUID or `job-` works well. The `sbx` CLI and both SDKs already send a generated key on create and fork. Pass your own key when the retry has to survive a process restart, for example a queue worker that crashes and picks up the same job again. ## Which errors to retry | Response | Retry? | How | | - | - | - | | Network error or timeout | Only for a GET, or a create or fork sent with a key | Backoff, same key | | `429 rate_limited`, `daily_limit_reached` | Yes | Wait for `Retry-After` (also `details.retryAfterSeconds`, `details.resetsAt`) | | `429 limit_reached` | No, not in a loop | Too many sandboxes are running at once. Stop one first | | `409 idempotency_in_progress`, `sandbox_starting`, `sandbox_restoring` | Yes | Short backoff | | `503 out_of_capacity`, `no_ready_machine`, `capacity_exceeded`; other `5xx` | Yes, a few times | Backoff. For `capacity_exceeded`, a smaller size may fit now (`details.availableBySize`) | | `409 idempotency_key_reused` | No | Use a new key for the new body | | Other `4xx` (`400`, `401`, `402`, `403`, `404`) | No | Fix the request, the key or the billing | This follows the [retry rules in the limits docs](/docs/limits): exponential backoff with jitter, starting near 500 ms and capped near 30 s. Log `requestId` with every failure. ## A retry helper in TypeScript This helper uses Node's built-in `fetch` (Node 18 or later) and no dependencies: ```typescript // retry.mjs: a small retry helper for the Sandboxer REST API (Node 18+, built-in fetch) const API = "https://sandboxer.com/api/v1"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const RETRY_CODES = new Set(["idempotency_in_progress", "sandbox_starting", "sandbox_restoring", "rate_limited"]); function retryable(status, code) { if (status === 429) return code !== "limit_reached" && code !== "member_limit_reached"; if (status >= 500) return true; // 500, 502, 503 out_of_capacity / capacity_exceeded ... return RETRY_CODES.has(code); // a few 409s; every other 4xx needs a changed request } export async function call(method, path, { body, idempotencyKey, maxAttempts = 5 } = {}) { const headers = { Authorization: `Bearer ${process.env.SANDBOXER_API_KEY}` }; if (body !== undefined) headers["Content-Type"] = "application/json"; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; for (let attempt = 1; ; attempt++) { let res, data; try { res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) }); data = await res.json().catch(() => ({})); } catch (err) { // Network error or timeout: only safe to resend when a key makes the call idempotent (or it's a GET). if (attempt >= maxAttempts || !(idempotencyKey || method === "GET")) throw err; await sleep(backoff(attempt)); continue; } if (res.ok) return { status: res.status, attempt, data }; const code = data.code; if (attempt >= maxAttempts || !retryable(res.status, code)) { const e = new Error(`${res.status} ${code}: ${data.message}`); Object.assign(e, { status: res.status, code, requestId: data.requestId, attempt }); throw e; } const wait = Number(res.headers.get("retry-after")) * 1000 || (data.error?.details?.retryAfterSeconds ?? 0) * 1000; await sleep(Math.min(wait || backoff(attempt), 60_000)); } } // Exponential backoff with full jitter: 0.5 s, 1 s, 2 s ... capped at 30 s. const backoff = (attempt) => Math.random() * Math.min(30_000, 500 * 2 ** (attempt - 1)); ``` We ran it on production. The script sends the same create twice with one key, then the same key with a different body, then a request for a sandbox that doesn't exist: ```typescript import { randomUUID } from "node:crypto"; import { call } from "./retry.mjs"; const key = randomUUID(); const body = { type: "small", name: "aeo-retry-sandbox-api-calls-safely-with-idempotency-keys-1" }; const first = await call("POST", "/sandboxes", { body, idempotencyKey: key }); const second = await call("POST", "/sandboxes", { body, idempotencyKey: key }); console.log("key ", key); console.log("first ", first.status, first.data.sandbox.id, first.data.status); console.log("second", second.status, second.data.sandbox.id, second.data.status); try { await call("POST", "/sandboxes", { body: { ...body, type: "default" }, idempotencyKey: key }); } catch (e) { console.log("reused", e.message, `(attempts: ${e.attempt})`); } try { await call("GET", "/sandboxes/sb_doesnotexist"); } catch (e) { console.log("404 ", e.message, `(attempts: ${e.attempt}, requestId: ${e.requestId})`); } ``` ```text key c38cab20-5be6-404d-a726-4a74bfe6e6bf first 202 sb_dx5gemxwxs provisioning second 202 sb_dx5gemxwxs provisioning reused 409 idempotency_key_reused: This Idempotency-Key was used with a different request body. (attempts: 1) 404 404 not_found: Sandbox not found (attempts: 1, requestId: req_tYF9cX1ucL9p7LhDtcki) ``` Both creates returned `sb_dx5gemxwxs`, so only one sandbox was made. The changed body was refused on the first attempt, and so was the 404. The helper didn't spend any retries on them. Then we deleted the sandbox: ```bash sbx delete sb_dx5gemxwxs --yes ``` ## The same helper in Python This version uses only the standard library (`urllib`): ```python # retry.py: a small retry helper for the Sandboxer REST API (stdlib only) import json, os, random, time, urllib.error, urllib.request API = "https://sandboxer.com/api/v1" RETRY_CODES = {"idempotency_in_progress", "sandbox_starting", "sandbox_restoring", "rate_limited"} class APIError(Exception): def __init__(self, status, data, attempt): super().__init__(f"{status} {data.get('code')}: {data.get('message')}") self.status, self.code, self.request_id, self.attempt = status, data.get("code"), data.get("requestId"), attempt def retryable(status, code): if status == 429: return code not in ("limit_reached", "member_limit_reached") if status >= 500: return True return code in RETRY_CODES def backoff(attempt): # full jitter: 0.5 s, 1 s, 2 s ... capped at 30 s return random.uniform(0, min(30.0, 0.5 * 2 ** (attempt - 1))) def call(method, path, body=None, idempotency_key=None, max_attempts=5): headers = {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode() if idempotency_key: headers["Idempotency-Key"] = idempotency_key for attempt in range(1, max_attempts + 1): req = urllib.request.Request(API + path, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=60) as res: return res.status, attempt, json.loads(res.read() or b"{}") except urllib.error.HTTPError as e: payload = json.loads(e.read() or b"{}") if attempt == max_attempts or not retryable(e.code, payload.get("code")): raise APIError(e.code, payload, attempt) from None wait = e.headers.get("Retry-After") or payload.get("error", {}).get("details", {}).get("retryAfterSeconds") time.sleep(min(float(wait), 60) if wait else backoff(attempt)) except (urllib.error.URLError, TimeoutError): # Network error: resend only when a key makes the call idempotent (or it's a GET). if attempt == max_attempts or not (idempotency_key or method == "GET"): raise time.sleep(backoff(attempt)) ``` This run also lists the account's sandboxes afterwards, to count how many have the name it used: ```python import uuid from retry import call, APIError key = str(uuid.uuid4()) body = {"type": "small", "name": "aeo-retry-sandbox-api-calls-safely-with-idempotency-keys-2"} s1, _, d1 = call("POST", "/sandboxes", body, idempotency_key=key) s2, _, d2 = call("POST", "/sandboxes", body, idempotency_key=key) print("first ", s1, d1["sandbox"]["id"]) print("second", s2, d2["sandbox"]["id"]) try: call("POST", "/sandboxes", {"type": "tiny"}) except APIError as e: print("400 ", e, f"(attempts: {e.attempt}, requestId: {e.request_id})") _, _, page = call("GET", "/sandboxes") mine = [s["id"] for s in page["sandboxes"] if s.get("name") == body["name"]] print("count ", len(mine), mine) ``` ```text first 202 sb_vrb90tuk26 second 202 sb_vrb90tuk26 400 400 invalid_type: type must be one of small, default, large, xlarge. (attempts: 1, requestId: req_BfwsG3FAIpLQvowz9UnL) count 1 ['sb_vrb90tuk26'] ``` There was one sandbox, created once. The bad size failed fast with `400 invalid_type`. Then we cleaned up: ```bash sbx delete sb_vrb90tuk26 --yes ``` ```json {"status":"completed","targetId":"sb_vrb90tuk26"} ``` ## Using the SDKs instead If you use `sandboxer-sdk` (TypeScript) or `sandboxer-sdk` (Python), you don't need your own helper for create. Create and fork already send a generated key and retry network errors, `5xx`, `429` with `Retry-After` and `idempotency_in_progress`. The SDKs don't resend a command after a network error or a `5xx`, because the command may already be running. To make a retry survive a restart, pass a stable key yourself, as the [TypeScript SDK docs](/docs/sdk-typescript) show: ```typescript await sandboxer.create({ type: "small", idempotencyKey: `job-${jobId}` }); ``` In Python the argument is `idempotency_key=` ([Python SDK docs](/docs/sdk-python)). ## Choosing a key - **One key per intended sandbox.** Derive it from the work, such as `job-` or `pr-412-attempt-1`, not from the time of the request. A key generated fresh on each retry protects nothing. - **Keep the body identical across retries.** Build the body once and resend the same object. Adding a timestamp or a random name to the body makes the replay a different request. - **A new attempt gets a new key.** If you deleted the first sandbox and want another one, change the key. Otherwise, for 24 hours, the old key keeps pointing at the first sandbox, even after you delete it. ## FAQ ### Which Sandboxer calls accept an Idempotency-Key? Creating a sandbox (`POST /sandboxes`) and forking one (`POST /sandboxes/{id}/fork`). Other calls ignore the header. They don't create billable sandboxes, so the error table above is enough to decide whether to retry them. ### What happens if I reuse a key with a different body? The server answers `409 idempotency_key_reused` and creates nothing. Don't retry that error. Send the new body with a new key. ### Should I retry a 429? Retry `rate_limited` and `daily_limit_reached` after the `Retry-After` header or `details.retryAfterSeconds`. Don't loop on `limit_reached`. It means too many sandboxes are running at once, and only stopping one frees a place. ### Is it safe to retry a command that timed out? Not blindly. A command may already be running inside the VM, so resending it can run it twice. Check the result first, or make the command itself idempotent. ### How long does a key last? 24 hours from its first use. After that, the same key creates a new sandbox. ## Related - [Limits and errors](/docs/limits) - [API reference](/docs/api) - [Give every user their own sandbox](/guides/give-every-user-their-own-sandbox) - [Run coding agents in parallel](/guides/run-coding-agents-in-parallel) --- # How Do I Run Best-of-N Agent Attempts With Parallel Forks? Source: https://sandboxer.com/guides/best-of-n-agents-with-parallel-forks. Updated 2026-10-27. **Short answer:** Prepare the repo once in a sandbox. Fork it once per attempt with sbx fork, apply each attempt in its own fork, run the test suite there, and save its git diff and score. Delete each fork when it is scored, keep the diff with the most passing tests, and apply it locally with git apply. ## The pattern | Step | Command | How often | | - | - | - | | Prepare: clone, install, commit a clean base | `sbx new`, then `sbx exec` | Once | | Copy the prepared disk per attempt | `sbx fork --wait` | N times, in parallel | | Make the attempt | Your agent edits files in the fork | Once per fork | | Score it | `sbx exec "./score.sh"` | Once per fork | | Keep the result, not the VM | `sbx exec "git diff" > n.diff`, then `sbx delete --yes` | Once per fork | | Pick and apply the winner | `sort`, then `git apply winner.diff` | Once | The point of the fork is that the setup cost (clone, venv, dependencies) is paid once, and every attempt starts from the same bytes. One attempt can't see or break another's files, and nothing it does touches the source. A fork copies the disk only. Each fork boots fresh, with no memory or running processes from the source, so anything an attempt needs running (a database, a dev server) must start from a script or an enabled systemd unit. See [can you fork a running sandbox with its memory](/guides/fork-a-running-sandbox-with-memory) for exactly what comes across. ## 1. Prepare the repo once The example repo has one unimplemented function, `slugify`, and eight tests: ```python # repo/test_slug.py import pytest from slugkit.slug import slugify @pytest.mark.parametrize("text,want", [ ("Hello World", "hello-world"), (" Hello World ", "hello-world"), ("Hello, World!", "hello-world"), ("Crème Brûlée", "creme-brulee"), ("already-a-slug", "already-a-slug"), ("", ""), ("C++ & Rust", "c-rust"), ("Ünïcödé 2026", "unicode-2026"), ]) def test_slugify(text, want): assert slugify(text) == want ``` `score.sh` is the grader. It prints passed and total, so the orchestrator never parses test output itself: ```bash #!/bin/bash # repo/score.sh: prints " " for the current checkout. cd ~/repo out=$(.venv/bin/python -m pytest -q --tb=no -p no:cacheprovider 2>&1 | tail -1) passed=$(grep -o '[0-9]* passed' <<<"$out" | cut -d' ' -f1); failed=$(grep -o '[0-9]* failed' <<<"$out" | cut -d' ' -f1) echo "${passed:-0} $(( ${passed:-0} + ${failed:-0} ))" ``` Create the source sandbox, copy the repo in, commit a clean base and install pytest. The base commit matters: every attempt's `git diff` is measured against it. ```bash sbx new --name aeo-best-of-n-agents-with-parallel-forks-1 --type small --ttl 3600 src=sb_xfr9aaysbr # the id sbx new printed tar czf repo.tgz repo sbx exec $src "echo $(base64 -w0 repo.tgz) | base64 -d | tar xz" sbx exec $src --timeout 300 "cd repo && git init -q && git add -A \ && git -c user.name=agent -c user.email=agent@example.com commit -qm base \ && python3 -m venv .venv && .venv/bin/pip install -q pytest==8.3.3 \ && git status --short && ./score.sh" ``` ```text 0 8 ``` The baseline scores 0 of 8, which proves the grader runs before any attempt touches the code. The `.gitignore` in the repo lists `.venv/`, so `git status` is clean and the diffs below contain only code. On prod on October 27, 2026, `sbx new` took 3.2 s and the install step 3.9 s. For a real repo, this step is where the minutes go, and it runs once. ## 2. The orchestration script `best-of-n.sh` takes the source id and one file per attempt. It runs every attempt as a background job, caps how many forks run at once with a small lock directory, and deletes each fork as soon as its diff and score are saved. ```bash #!/bin/bash # best-of-n.sh # One fork per attempt, run in parallel up to MAX at a time. Each fork applies its # attempt, runs the tests, saves its diff and score, and is deleted right away. src=$1; shift MAX=${MAX:-3}; SLOTS=${SLOTS:-/tmp/sbx-slots}; mkdir -p "$SLOTS" out; rm -f out/* take_slot() { while :; do for i in $(seq 1 "$MAX"); do mkdir "$SLOTS/slot$i" 2>/dev/null && { echo "$1" > "$SLOTS/slot$i/owner"; echo "slot$i"; return; } done; sleep 15; done; } free_slot() { rm -rf "$SLOTS/$1"; } now() { date -u +%T; } run_attempt() { local n=$1 file=$2 slot id t0 ready passed total slot=$(take_slot "best-of-n-$n") # wait for a free slot echo "$(now) attempt $n: got $slot, forking" t0=$(date +%s.%N) id=$(sbx fork "$src" --type small --ttl 900 --wait --json | jq -r 'select(.event=="ready") | .id') ready=$(printf '%.1f' "$(echo "$(date +%s.%N) - $t0" | bc)") echo "$(now) attempt $n: $id ready in ${ready}s" sbx rename "$id" --name "aeo-best-of-n-agents-with-parallel-forks-fork$n" > /dev/null # The "agent" step: this attempt's version of the file. A model would edit here. sbx exec "$id" "echo $(base64 -w0 "$file") | base64 -d > ~/repo/slugkit/slug.py" read -r passed total < <(sbx exec "$id" "~/repo/score.sh") sbx exec "$id" "cd ~/repo && git diff" > "out/$n.diff" echo "$n $id $ready $passed $total" > "out/$n.score" sbx delete "$id" --yes > /dev/null && echo "$(now) attempt $n: $passed/$total passed, deleted $id" free_slot "$slot" } t0=$(date +%s); n=0 for f in "$@"; do n=$((n+1)); run_attempt "$n" "$f" & done wait echo "all $n attempts done in $(( $(date +%s) - t0 ))s" echo "attempt id forkReadySeconds passed total"; cat out/*.score win=$(sort -k4,4nr out/*.score | head -1 | cut -d' ' -f1) cp "out/$win.diff" winner.diff && echo "winner: attempt $win -> winner.diff" ``` Notes on the choices: - **`--ttl 900` on every fork.** A fork doesn't inherit the source's TTL; it gets one hour unless you pass one. A short TTL is the backstop if the script dies between `fork` and `delete`. - **`select(.event=="ready")`.** With `--wait --json`, `sbx fork` prints JSON Lines (`forking`, `state`, `ready`). Taking the `ready` line means the fork already answers commands. - **Keep the diff, delete the VM.** The diff is the whole result. Holding forks open while you compare them costs money and capacity for nothing. - **The attempt step is a file copy.** We didn't call a model for this page. In a real run, that line is where your agent works in the fork, with its own prompt per attempt. Everything else ran as shown. ## 3. Run it with three attempts The three attempts are three candidate implementations an agent might write: | Attempt | Approach | | - | - | | 1 | `text.lower().replace(" ", "-")` | | 2 | Lowercase, replace every run of non `[a-z0-9]` characters with `-`, strip dashes | | 3 | Same as 2, after `unicodedata.normalize("NFKD", …)` and dropping non-ASCII marks | ```bash SLOTS=/tmp/aeo-gw/slots MAX=5 ./best-of-n.sh sb_xfr9aaysbr attempts/1.py attempts/2.py attempts/3.py ``` On your own account, leave `SLOTS` at its default and set `MAX` to the number of forks you want running at once. The output from prod on 2026-10-27: ```text 10:04:51 attempt 1: got slot5, forking 10:05:02 attempt 1: sb_k82853nxt6 ready in 11.8s 10:05:05 attempt 1: 3/8 passed, deleted sb_k82853nxt6 10:05:06 attempt 2: got slot5, forking 10:05:09 attempt 2: sb_clntkiof59 ready in 3.4s 10:05:12 attempt 2: 6/8 passed, deleted sb_clntkiof59 10:14:51 attempt 3: got slot4, forking 10:14:54 attempt 3: sb_xivbod0meq ready in 3.4s 10:14:57 attempt 3: 8/8 passed, deleted sb_xivbod0meq all 3 attempts done in 622s attempt id forkReadySeconds passed total 1 sb_k82853nxt6 11.8 3 8 2 sb_clntkiof59 3.4 6 8 3 sb_xivbod0meq 3.4 8 8 winner: attempt 3 -> winner.diff ``` **Read the timestamps before the totals.** Our test account was shared with other jobs that night, under a cap of five running sandboxes in total, so we pointed the script's lock at the shared one (`SLOTS=/tmp/aeo-gw/slots MAX=5`). Only one slot was free at a time, so the three attempts ran one after another, and attempt 3 waited almost 10 minutes for a slot. That wait is the 622 s total. On an account with free capacity, the three background jobs start their forks at the same moment instead of waiting for each other. We couldn't measure that overlap in this run, so we have no parallel timing to show. What each attempt cost in sandbox time, from fork to delete: | Attempt | Fork ready | Fork to deleted | Tests passed | Fails on | | - | - | - | - | - | | 1 | 11.8 s | 14 s | 3 of 8 | Extra spaces, punctuation, accents, `C++ & Rust` | | 2 | 3.4 s | 6 s | 6 of 8 | `Crème Brûlée` became `cr-me-br-l-e`; the same for `Ünïcödé` | | 3 | 3.4 s | 6 s | 8 of 8 | Nothing | These are single runs, not benchmarks. The first fork took 11.8 s and the next two 3.4 s each; we didn't find out why. At the `small` price of $0.018/hour, the three forks together ran for about 26 seconds. The source was untouched. After the run, `git status --short` in its repo was empty and `slug.py` still raised `NotImplementedError`. ## 4. Apply the winner `winner.diff` is a normal `git diff` against the base commit: ```diff diff --git a/slugkit/slug.py b/slugkit/slug.py index 5e68d2c..05c8874 100644 --- a/slugkit/slug.py +++ b/slugkit/slug.py @@ -1,3 +1,7 @@ +import re +import unicodedata + def slugify(text: str) -> str: """Turn a title into a URL slug.""" - raise NotImplementedError + ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-") ``` Apply it in your local checkout of the same base: ```bash git apply --stat winner.diff && git apply winner.diff python3 -c "from slugkit.slug import slugify; print(slugify('Crème Brûlée'), slugify('C++ & Rust'))" ``` ```text slugkit/slug.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) creme-brulee c-rust ``` Finally, delete the source: ```bash sbx delete sb_xfr9aaysbr --yes ``` ## Choosing the winner well - **Break ties on something real.** Two attempts can both pass everything. Sort by passed tests, then by diff size or runtime, rather than taking whichever finished first. - **Hide the tests if the agent could game them.** An agent that can read the tests can special-case them. Copy the hidden test file into the fork with `sbx exec` only after the attempt has finished, then score. - **Watch your start limits.** Each fork counts as one start against your plan's start limits, like a create or a resume. Check what's left with `sbx limits --json | jq .starts` before a large N (see [limits](/docs/limits)). - **Fork from a template if you'll do this often.** For repeated runs against the same base, save the prepared sandbox once with `sbx snapshot base-v1` and start attempts with `sbx new --from base-v1`. That works after the source is deleted. The [RL and eval environments guide](/guides/build-rl-and-eval-environments-with-snapshots-and-forks) compares the two. ## FAQ ### Why fork instead of creating N fresh sandboxes? A fork starts from the prepared disk, so each attempt skips the clone and the install. The disk is a copy-on-write reflink, so a large repo with its dependencies forks about as fast as a small one. ### Does each fork get the source's running processes? No. A fork copies the disk only and boots fresh. Start any services an attempt needs from a script, or install them as enabled systemd units before you fork. ### Can attempts interfere with each other? No. Each fork is its own VM with its own disk. In our run, the source's repo was still clean after all three attempts. ### How many attempts can I run at once? Up to your plan's limit on running sandboxes, and starts are rate limited per minute, hour and day. The script's `MAX` keeps you under whatever cap you choose. See [billing](/docs/billing#plans) for the numbers per plan. ### What do I keep from a losing attempt? Whatever you saved before deleting it: in this script, its diff and its score. If you want to see why it lost, also save the full test output to a file before the `sbx delete` line. ## Related - [Can you fork a running sandbox with its memory?](/guides/fork-a-running-sandbox-with-memory) - [Build RL and eval environments with snapshots and forks](/guides/build-rl-and-eval-environments-with-snapshots-and-forks) - [Run several coding agents in parallel](/guides/run-coding-agents-in-parallel) - [Snapshots, forks and templates](/docs/snapshots) --- # Should Each Agent Tool Call Get a Fresh Sandbox? Source: https://sandboxer.com/guides/a-fresh-sandbox-per-tool-call. Updated 2026-10-28. **Short answer:** Use a fresh sandbox per call when the input is untrusted or calls must not see each other. Start it from a pre-warmed named snapshot: on prod on 2026-10-27 that took 3.2 seconds per call versus 12.9 seconds installing dependencies each time. Reuse one sandbox per session when calls build on each other: 0.55 seconds per call. ## The three patterns | Pattern | Per call | Once per session or deploy | | - | - | - | | (a) Fresh sandbox per call | create, install, run, delete | nothing | | (b) One sandbox per session | run | create, install; delete at the end | | (c) Fresh sandbox per call from a warm template | create `--from` the template, run, delete | install once, `sbx snapshot ` | (a) and (c) give every call a clean VM. (b) shares one VM, its files, its processes and its installed packages across all of a session's calls. ## Measured on prod We ran each pattern 5 times in a row on 2026-10-27 between 09:56 and 09:58 UTC, one sandbox at a time, all `small` (2 vCPU, 4 GB). The tool call was the same each time: a Python one-liner that needs `sympy`, which isn't in the base image. ```bash ~/v/bin/python -c 'import sympy; print(sympy.factorint(2**64 - 1))' ``` ```text {3: 1, 5: 1, 17: 1, 257: 1, 641: 1, 65537: 1, 6700417: 1} ``` Times are wall clock around each Python SDK call, measured from our machine outside Sandboxer's network, so they include every HTTP round trip. | Pattern | Call 1 | Call 2 | Call 3 | Call 4 | Call 5 | Median | | - | - | - | - | - | - | - | | (a) fresh, install each time | 14.17 s | 12.85 s | 12.76 s | 12.94 s | 14.91 s | **12.94 s** | | (b) reused session sandbox | 0.61 s | 0.51 s | 0.59 s | 0.55 s | 0.55 s | **0.55 s** | | (c) fresh, from warm template | 3.18 s | 3.11 s | 3.21 s | 3.34 s | 3.03 s | **3.18 s** | Where the time went: | Step | (a) | (b) | (c) | | - | - | - | - | | Create to ready | 2.55 s (4.27 s on call 1) | 2.62 s, once | 2.55 s | | `venv` + `pip install sympy` | 9.36 to 11.83 s | 10.19 s, once | 0 (already in the template) | | Run the tool | 0.50 to 0.56 s | 0.51 to 0.61 s | 0.45 to 0.75 s | | Delete call returns | 30 ms | 30 ms, once | 30 ms | | One-time template save | | | 2.09 s (`sandbox.snapshot(name)`) | Three things stand out: - **The install, not the VM, is the slow part of (a).** Create to ready was 2.55 seconds on 9 of 10 fresh creates. The `pip install` was 9 to 12 seconds every time. - **A template removes the install, not the boot.** (c) still pays the 2.55-second create, so it is about 2.6 seconds slower per call than reuse. Creates from the standard image have been faster since October 28, 2026, but a create from a snapshot, like (c), still boots; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). - **Delete is cheap to wait for.** The delete call returned in 30 ms; the VM is torn down in the background. For how create, resume and fork times vary over more runs, see [How fast does a cloud sandbox start, resume and fork](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). ## What each call costs Billing is per second from when a sandbox is up until it stops or is deleted. `small` is $0.018/hour, which is $0.000005 per second. The numbers below charge each call for its full wall time, so they're an upper bound: the meter doesn't start until the VM is up. | Pattern | Billed time per call (at most) | Per call | Per 1,000 calls | | - | - | - | - | | (a) fresh, install each time | 13.5 s (mean of 5) | $0.000068 | $0.068 | | (c) fresh, from warm template | 3.2 s (mean of 5) | $0.000016 | $0.016 | | (b) reused session sandbox | The whole session, not the call | Depends on the gaps | Depends on the gaps | (b) is the one to watch. A session sandbox bills while the model is thinking between calls, not only while a command runs. With 10 seconds of model time between calls, each call costs about 10.6 seconds of `small` time ($0.000053), which is more than (c). Once the gap between calls is longer than about 2.6 seconds, a template-backed fresh sandbox per call costs less than holding one open. Always give a session sandbox a TTL or an idle pause so a crashed agent doesn't keep billing. A `default` sandbox costs twice these numbers. The template also takes storage while it exists; storage isn't billed, but it counts toward your plan's included amount. See [Billing](/docs/billing). ## When isolation per call is worth it Pick a fresh sandbox per call, (a) or (c), when any of these is true: - **The input is untrusted.** Code written by a model from a user's prompt, a file a user uploaded, or a repository you didn't write. A fresh `noEnv` sandbox holds none of your secrets, and its `SANDBOXER_TOKEN` can only read that sandbox and host its ports. Whatever the code does is gone when you delete it. - **Calls belong to different users or tenants.** One call must never read another's files or see its processes. - **You need reproducible results.** Evals, graders and tests that must start from the same disk every time. - **A call might wreck the machine.** `rm -rf`, a fork bomb or a full disk only takes down that call's VM. Pick one sandbox per session, (b), when: - **Calls build on each other.** The agent writes a file in one call and runs it in the next, or keeps a dev server or REPL running. - **The inputs are yours or already trusted.** Your own agent working on your own repo. - **Latency matters more than cleanliness.** 0.55 seconds per call against 3.2. Mixed is common: a session sandbox for the agent's own work, plus a fresh `noEnv` sandbox for the one tool that runs user-supplied code. ## The code This is the shape we used, with the Python SDK (`pip install sandboxer-sdk`). We ran this exact file on prod once per pattern after the benchmark. ```python from sandboxer import Sandboxer client = Sandboxer() # reads SANDBOXER_API_KEY SETUP = "python3 -m venv ~/v && ~/v/bin/pip install -q sympy" TEMPLATE = "tool-call-template" def run_fresh(code: str) -> str: """(a) A new sandbox for this one call, deleted afterwards.""" sb = client.create(type="small", ttl_seconds=600, no_env=True, name="tool-call-a") try: sb.exec(SETUP, timeout_seconds=300, check=True) return sb.exec(["/home/user/v/bin/python", "-c", code], check=True).stdout finally: sb.delete() class Session: """(b) One sandbox for the whole agent session, reused by every call.""" def __init__(self): self.sb = client.create(type="small", ttl_seconds=1800, name="tool-call-b") self.sb.exec(SETUP, timeout_seconds=300, check=True) def run(self, code: str) -> str: return self.sb.exec(["/home/user/v/bin/python", "-c", code], check=True).stdout def close(self): self.sb.delete() def run_from_template(code: str) -> str: """(c) A new sandbox per call, started from a pre-warmed named snapshot.""" sb = client.create(type="small", ttl_seconds=600, no_env=True, from_=TEMPLATE, name="tool-call-c") try: return sb.exec(["/home/user/v/bin/python", "-c", code], check=True).stdout finally: sb.delete() if __name__ == "__main__": code = "import sympy; print(sympy.isprime(2**61 - 1))" print("a:", run_fresh(code).strip()) s = Session() try: print("b:", s.run(code).strip(), s.run("print(1 + 1)").strip()) s.sb.snapshot(TEMPLATE) # the warmed disk becomes the template for (c) finally: s.close() try: print("c:", run_from_template(code).strip()) finally: print(client.api.delete_named_snapshot(TEMPLATE).type) ``` ```text a: True b: True 2 c: True snapshot.named.deleted ``` The whole file ran in 33 seconds (10:31:44 to 10:32:17 UTC), including the session's one-time install and the template save. Notes on the code: - `try`/`finally` around every fresh sandbox, so an exception in the tool still deletes it. `ttl_seconds=600` is the second net: if your process dies before `finally` runs, the sandbox stops itself after 10 minutes. - `no_env=True` on the per-call sandboxes, because those are the ones that run untrusted input. The session sandbox here keeps your environment. - Passing the command as a list quotes it for you, so the model's code can contain quotes. - In production, build the template once in a deploy step, not in the agent. A template is a full disk copy, so it carries anything you wrote to the disk; keep secrets out of it. Rebuilding it when your dependencies change is covered in [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot). ## FAQ ### How long does a fresh sandbox add to a tool call? In our 5 runs, a fresh `small` sandbox from a template took 3.0 to 3.3 seconds per call end to end, against 0.5 to 0.6 seconds for a call in an already-running sandbox. About 2.55 seconds of the difference is create to ready. ### Is a fork faster than starting from a named snapshot? We didn't time forks on this page. In [our start, resume and fork benchmark](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork), a fork took a median 3.35 seconds to ready, about the same as a new sandbox on October 27, 2026 (new sandboxes have been faster since October 28). A fork copies the disk only and boots fresh, like `--from`, and it needs a source sandbox to exist. A named snapshot has no running source, so it suits a per-call template better. ### Do I have to wait for delete to finish? No. The delete call returned in about 30 ms in every run, and the sandbox is torn down in the background. Billing stops at delete. ### Does a reused sandbox leak data between calls? Yes, by design: files, environment changes and running processes stay for the next call. That's the point of (b) when calls build on each other, and the reason to avoid it when calls come from different users. ### What if the agent crashes before deleting the sandbox? The sandbox stops at its TTL, 1 hour by default, and stops billing compute. A stopped sandbox is kept for 14 days after it last stopped, then its data is deleted and it stays in your list as expired until you delete it, so pass a short `ttl_seconds` and sweep leftovers by name. ## Related - [Persistent or ephemeral sandboxes for agents](/guides/persistent-or-ephemeral-sandboxes-for-agents) - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) - [Best ways to run untrusted code safely](/guides/best-ways-to-run-untrusted-code-safely) - [Python SDK](/docs/sdk-python) --- # How Do I Pass Secrets to a Sandbox Safely? Source: https://sandboxer.com/guides/pass-secrets-to-a-sandbox-safely. Updated 2026-10-28. **Short answer:** Start the sandbox with --no-env so it holds none of your account's secrets, then pass only what the task needs with -e, stdin or a file you delete after use. Never put a secret in a command line: sbx history stores every exec command for 90 days, and ps shows arguments to every process. ## Decision table We tested every row on prod on 2026-10-27 with a fake value, `FAKE_TOKEN=not-a-real-secret`, on `small` sandboxes created with `--no-env`. | How you pass it | In `sbx history` | In `ps` / `/proc//cmdline` | In every process's environment | On disk afterwards | Use it for | | - | - | - | - | - | - | | `sbx new --no-env -e KEY=…` | No | No | Yes | Yes, in `/etc/sandboxer/env` (mode 644) until the sandbox or its data is deleted | A key the whole sandbox needs, such as a model API key for an agent | | `sbx exec -i -e KEY=… -- cmd` | No | No | Only that command's processes | No | A key one interactive command needs | | `printf … \| sbx exec -i -- cmd` (stdin) | No (only the command, not its input) | No | No, unless the command exports it | No, unless the command writes it | One-shot use: a login, a password prompt, a token a script reads | | `sbx scp` a file, use it, `rm` it | No | No | No, unless you `source` it | Until you delete it | Config files: `.npmrc`, a service-account JSON, a kubeconfig | | `sbx exec "KEY=… cmd"` | **Yes, 90 days** | Yes while it runs | That command's processes | No | Never for a real secret | | `cmd --token=…` as an argument | **Yes, 90 days** | **Yes**, visible to every user | No | No | Never for a real secret | | `echo $KEY` (printing it) | **Yes**: the output is stored too | No | — | — | Never: print the length instead | | An [environment](/docs/environments) (`sbx env set-var`) | No | No | Yes, in every sandbox on that environment | Yes, until removed and upgraded | Values shared by all your own sandboxes; not for sandboxes you hand to someone else | Add `--no-monitoring` when even the command lines must not be kept: then there is no history at all. ## Start from nothing: `--no-env` A normal sandbox starts with your [environment](/docs/environments): its variables, secret files, GitHub token and agent logins. A sandbox for an agent you don't fully trust, or for another person, should start with none of that. ```bash sbx new --name aeo-pass-secrets-to-a-sandbox-safely-1 --type small --no-env -e FAKE_TOKEN=not-a-real-secret sbx info sb_vq3kb9cr4w --json | tr ',' '\n' | grep -E '"(noEnv|environment|holdsCreatorLogins)"' ``` ```text "environment":null "holdsCreatorLogins":false "noEnv":true ``` Inside, the only variables Sandboxer set are its own four, plus the one we passed: ```bash sbx exec sb_vq3kb9cr4w 'env | grep -E "^(FAKE|SANDBOXER)" | sed -E "s/(SANDBOXER_TOKEN=).*/\1/"' ``` ```text SANDBOXER_API_URL=https://sandboxer.com/api/v1 FAKE_TOKEN=not-a-real-secret SANDBOXER_ID=sb_vq3kb9cr4w SANDBOXER_SUBDOMAIN=civic-lively-kiwi SANDBOXER_TOKEN= ``` The value you pass with `-e` is not returned by the API: `sbx info --json` for this sandbox didn't contain `not-a-real-secret`. ### The token that is always there Every sandbox gets `SANDBOXER_TOKEN`. In a `--no-env` sandbox it is limited to `sandbox.read` and `host` on that one sandbox. We called the API with it from inside: ```text GET /sandboxes 200 (lists only sb_vq3kb9cr4w) GET /sandboxes/sb_vq3kb9cr4w 200 GET /me 403 GET /secrets 403 POST /sandboxes 403 POST /sandboxes/sb_vq3kb9cr4w/commands 403 ``` It can't create sandboxes, run commands, read your account or read your stored secrets. In a sandbox without `--no-env`, the token is broader (exec, files, SSH on that sandbox); see [API keys](/docs/api-keys#the-in-sandbox-key). ## What an agent inside can read Treat anything you put in the sandbox as readable by the agent. It runs as `user` with `sudo`, so file modes don't protect a secret from it. ```bash sbx exec sb_vq3kb9cr4w 'ls -la /etc/sandboxer/' sbx exec sb_vq3kb9cr4w 'cat /etc/profile.d/sandboxer.sh' ``` ```text -rw-r--r-- 1 root root 283 Oct 27 09:13 env -rw-r--r-- 1 root root 107 Oct 27 09:13 sandbox.json … export FAKE_TOKEN='not-a-real-secret' … ``` That second command also printed the sandbox's own `SANDBOXER_TOKEN` in full, and its output is now in `sbx history`. That's the next problem. `-e` values sit in `/etc/sandboxer/env` and `/etc/profile.d/sandboxer.sh`, and every process inherits them. A long-running process shows them in `/proc//environ`: ```bash pid=$(sbx exec sb_vq3kb9cr4w --detach 'sleep 300' | jq -r .processId) sbx exec sb_vq3kb9cr4w "tr '\0' '\n' < /proc/$pid/environ | grep FAKE" ``` ```text FAKE_TOKEN=not-a-real-secret ``` So the rule is not "hide it from the agent". It's "give the agent only a key you can afford to lose": a dedicated key with a spend limit, scoped to the job, revoked when the job ends. ## Keep secrets out of history and logs Every command run through `exec` or the API is stored with its output for 90 days, readable even while the sandbox is stopped ([command history](/docs/long-running#command-history)). That includes inline variables: ```bash sbx exec sb_vq3kb9cr4w 'ARGV_TOKEN=not-a-real-secret-argv sh -c "echo ran with \${#ARGV_TOKEN} chars"' sbx history sb_vq3kb9cr4w --grep not-a-real-secret-argv ``` ```text COMMAND-ID STATUS MODE STARTED COMMAND cmd_7w4liq4a7jqv running detached 2026-10-27 09:13:56 python3 -c "import time; time.sleep(300)" --token=not-a-real-secret-a… cmd_shonp7wzmfw4 exit 0 sync 2026-10-27 09:13:43 ARGV_TOKEN=not-a-real-secret-argv sh -c "echo ran with \${#ARGV_TOKEN… ``` The second row is the inline variable. The first is a secret passed as a program argument, which is worse: while it runs, any process in the sandbox sees it, and `sbx ps` reports it too. ```bash sbx exec sb_vq3kb9cr4w 'ps -eo pid,user,args | grep -v grep | grep not-a-real' ``` ```text 581 user python3 -c import time; time.sleep(300) --token=not-a-real-secret-argv ``` Stored output counts too. `echo token is $FAKE_TOKEN` didn't put the value in the command line, but `sbx history ` printed `token is not-a-real-secret` back. Print `${#KEY}` (the length) when you need to check that a variable is set. Now the same check with stdin and with `exec -i -e`: ```bash printf 'not-a-real-secret-stdin\n' | sbx exec -i sb_vq3kb9cr4w -- bash -c 'read -r T; echo "read ${#T} chars from stdin"' sbx exec -i -e EXEC_TOKEN=not-a-real-secret-exec-e sb_vq3kb9cr4w -- sh -c 'echo "EXEC_TOKEN has ${#EXEC_TOKEN} chars"' ~/.fake2.env; ls -l ~/.fake2.env; shred -u ~/.fake2.env; echo removed' ``` ```text -rw------- 1 user user 40 Oct 27 09:14 /home/user/.fake2.env removed ``` Neither value showed up in `sbx history --grep`. Two cautions. A disk snapshot taken while the file exists keeps it, so delete files before you `sbx stop`, fork or save a named snapshot. And for files every one of your own sandboxes needs, an environment's secret files are simpler: removing one and running `sbx env upgrade` deletes it from the sandboxes' disks ([security docs](/docs/security#secrets)). ## Use short-lived, scoped keys The secret most worth protecting is your Sandboxer key itself. Never pass your own key into a sandbox. If code inside must call the API beyond what `SANDBOXER_TOKEN` allows, create a key just for that job: ```bash # A key for one sandbox, two actions, one day. Keep the secret in a variable; don't print it. JOBKEY=$(sbx api-key create pass-secrets-job --sandbox sb_t04alf2wbk \ --actions sandbox.read,exec --ttl 1d --json | jq -r .secret) SANDBOXER_API_KEY="$JOBKEY" sbx exec sb_t04alf2wbk 'echo hello from the job key' SANDBOXER_API_KEY="$JOBKEY" sbx list --json | jq -r '.sandboxes[].id' SANDBOXER_API_KEY="$JOBKEY" sbx new --type small sbx api-key revoke key_P1TEofOrnhFzgAKv SANDBOXER_API_KEY="$JOBKEY" sbx exec sb_t04alf2wbk 'echo after revoke' ``` ```text hello from the job key sb_t04alf2wbk {"event":"error","error":"This API key does not allow sandbox.create.","code":"api_key_action_forbidden","status":403,…} {"ok":true,"type":"api_key.revoked"} {"event":"error","error":"Unauthorized","code":"unauthorized","status":401,…} ``` The key saw only its sandbox, couldn't create another, and stopped working the moment we revoked it. Unrevoked, it would have expired after a day. For per-user keys in a product backend, see [give every user their own sandbox](/guides/give-every-user-their-own-sandbox). | Preset | Gives | | - | - | | `read-only` | Read sandboxes, files, snapshots, environments | | `full-sandbox` | Operate existing sandboxes; no create, resume, fork, delete or account writes | | `ci` | `full-sandbox` plus create, resume, fork and delete only its own sandboxes | | `admin` | Everything; keep it off machines you don't control | Add `--sandbox ` to pin a key to one sandbox and `--ttl` to make it expire. Use `sbx login --key-stdin` so a key stays out of process arguments on your own machine too. The full list of actions is in [API keys](/docs/api-keys#scopes). ## FAQ ### Does `sbx history` store environment variables I pass with `-e`? No. We passed values with `sbx new -e` and `sbx exec -i -e`, and neither appeared in `sbx history` or `sbx info`. Inline `KEY=value cmd` in a command line is stored, because it is part of the command. ### Can the agent in the sandbox read my secret? Yes, if it's in the sandbox. Variables are in `/etc/sandboxer/env` and every process's environment, and the agent has `sudo`. Give it only keys you'd be fine rotating, with the narrowest scope and a short expiry. ### Is `SANDBOXER_TOKEN` a risk in a `--no-env` sandbox? It's limited to reading that one sandbox and hosting its ports. In our test it could list only its own sandbox and got `403` for `/me`, `/secrets`, creating sandboxes and running commands. ### Do secrets survive a fork or a snapshot? A fork copies the disk, and stop and resume keep it, so any secret file you wrote is in the copy. A fork of a `--no-env` sandbox is always `--no-env`. Delete secret files before you fork, stop or save a named snapshot. ### How do I remove a secret after the job? Delete the sandbox, then revoke the key you gave it with `sbx api-key revoke`. For a value in an environment, remove it and run `sbx env upgrade`, which deletes it from running sandboxes' disks. ## Related - [Environments and secrets](/docs/environments) - [API keys](/docs/api-keys) - [Give every user their own sandbox](/guides/give-every-user-their-own-sandbox) - [Run Claude Code without permission prompts, safely](/guides/claude-code-skip-permissions-safely) --- # How Do I Cache Dependencies in a Sandbox With a Named Snapshot? Source: https://sandboxer.com/guides/cache-dependencies-in-a-named-snapshot. Updated 2026-10-27. **Short answer:** Install dependencies once in a sandbox, save it with sbx snapshot under a name that includes a hash of your lockfiles, and start each job with sbx new --from that name. On prod this replaced about 51 seconds of npm, uv and pip installs with a hash check. Rebuild the snapshot when the hash changes. ## Results We ran this on prod on 2026-10-27 on `default` sandboxes (4 vCPU, 8 GB) in eu-central. Three runs per row; the median is in bold. | Install | Fresh sandbox, empty cache | From the snapshot, cache only (`node_modules` / venv deleted) | From the snapshot, as saved | | - | - | - | - | | `npm ci`, Next.js app, 1,455 packages | 23.4, **23.8**, 25.3 s | 17.5, **17.9**, 18.3 s | **0 s** (skipped) | | `uv sync --frozen`, FastAPI app, 77 packages | 3.1, **1.9**, 1.9 s | 0.1, **0.0**, 0.0 s | **0 s** (skipped) | | `pip install -r requirements.txt`, same FastAPI deps | 25.5, **25.5**, 23.2 s | 18.1, **18.4**, 18.5 s | **0 s** (skipped) | | **Total** | **51.2 s** | **36.3 s** | **0.00 s** hash check | The projects were two real public repos at their current commits: [mckaywrigley/chatbot-ui](https://github.com/mckaywrigley/chatbot-ui) (Next.js 14, `package-lock.json`, commit `81328b6`) and [fastapi/full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template) (`uv.lock`, commit `f4bcfab`). The pip row installs the same locked dependencies, exported with `uv export`, into a plain `python3 -m venv`. Creating the sandbox cost the same either way: `sbx new` and `sbx new --from` each reported ready in 3.2 seconds (one run each, October 27, 2026). Since October 28, 2026, a create from the standard image is faster than one from a snapshot; see [start, resume and fork times](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). What the numbers say: - **The big win is skipping the install, not the warm cache.** With `node_modules` and the venv already on disk, there is nothing to do. A warm npm or pip cache alone saved only about a quarter of the time, because both still unpack and link every package. - **uv is already fast.** A cold `uv sync` took under 2 seconds once its Python was present (run 1 also fetched the project's managed CPython 3.14). The snapshot still matters for uv when you use it to skip a slow toolchain or a large wheel set, but the per-job saving here was small. - **Measure your own project.** These are network-bound installs on one day. A project with native builds (node-gyp, Rust wheels, source-built Python packages) gains more. ## Step 1: name the snapshot after your lockfiles A named snapshot has no versions: saving again under the same name replaces it. So put the version in the name. Hash the lockfiles, and the name changes exactly when your dependencies do: ```bash cat web/package-lock.json api/uv.lock | sha256sum | cut -c1-12 ``` ```text 312bbcdb2bce ``` The snapshot is then `chatbot-deps-312bbcdb2bce`. Names are 1 to 63 lowercase letters, digits or dashes, so keep the prefix short. ## Step 2: a script that reuses or rebuilds it `deps-snapshot.sh` prints the snapshot name for the current lockfiles. If no snapshot has that name, it builds one in a throwaway sandbox, saves it, deletes the builder, and removes older snapshots with the same prefix. It needs `sbx` and `jq` on the machine that runs it (your laptop or CI runner). ```bash #!/usr/bin/env bash # Print the name of a named snapshot whose dependencies match these lockfiles. # Build it first if there is none. # Usage: deps-snapshot.sh '' ... set -euo pipefail prefix=$1 install=$2; shift 2 # 1. Name the snapshot after a hash of the lockfiles. hash=$(cat "$@" | sha256sum | cut -c1-12) name="$prefix-$hash" # 2. Reuse it if it exists. if sbx snapshots --json | jq -e --arg n "$name" '.named[] | select(.name == $n)' >/dev/null; then echo "$name"; exit 0 fi # 3. Build it: fresh sandbox, install, save, delete the builder. id=$(sbx new --name "build-$name" --type default --json | jq -r 'select(.event == "ready") | .id') trap 'sbx delete "$id" --yes >/dev/null' EXIT sbx exec "$id" --timeout 600 "$install && echo $hash > ~/.deps-hash" >&2 sbx snapshot "$id" "$name" >&2 # 4. Remove older snapshots with the same prefix. sbx snapshots --json | jq -r --arg p "$prefix-" --arg n "$name" '.named[] | select((.name | startswith($p)) and .name != $n) | .name' | while read -r old; do sbx snapshot rm "$old" >&2; done echo "$name" ``` The install command for our two projects, in `install.sh`. It runs as `user` in `/home/user`, so the npm, pip and uv caches land in the home directory and are saved with the disk: ```bash set -e git clone -q --depth 1 https://github.com/mckaywrigley/chatbot-ui web git clone -q --depth 1 https://github.com/fastapi/full-stack-fastapi-template api cd ~/web && npm ci --no-audit --no-fund --loglevel=error cd ~/api && uv sync --frozen --quiet uv export --frozen --no-hashes --no-emit-workspace --quiet > requirements.txt python3 -m venv pipvenv && pipvenv/bin/pip install -q -r requirements.txt ``` Run it from a checkout that has the same lockfiles: ```bash time ./deps-snapshot.sh chatbot-deps "$(cat install.sh)" web/package-lock.json api/uv.lock ``` The first run built the snapshot (output trimmed): ```text added 1455 packages in 24s {"ok":true,"type":"snapshot.named.info","snapshot":{"name":"chatbot-deps-312bbcdb2bce",…,"status":"ready","type":"default",…}} chatbot-deps-312bbcdb2bce real 1m1.016s ``` The second run found it and returned at once: ```text chatbot-deps-312bbcdb2bce real 0m0.133s ``` The builder sandbox was gone afterwards (`sbx info` returned `not_found`), because the `trap` deletes it even if the install fails. ## Step 3: start every job from it ```bash name=$(./deps-snapshot.sh chatbot-deps "$(cat install.sh)" web/package-lock.json api/uv.lock) sbx new --from "$name" ``` The new sandbox takes the snapshot's size (`default` here) unless you pass `--type`. Its dependencies work straight away: ```bash sbx exec sb_z6j5c9bgpc 'cd ~/web && npx next --version; cd ~/api && uv run --frozen python -c "import fastapi, sqlmodel; print(\"fastapi\", fastapi.__version__)"' ``` ```text Next.js v14.1.0 fastapi 0.141.1 ``` ## Step 4: check the hash inside the sandbox The job may check out a newer commit than the one in the snapshot. Put a guard at the start of the job: skip the install when the lockfiles still match the hash saved at build time, and fall back to an install from the warm cache when they don't. ```bash #!/usr/bin/env bash # deps-check.sh: run at the start of every job in a sandbox made with --from. set -euo pipefail want=$(cat ~/web/package-lock.json ~/api/uv.lock | sha256sum | cut -c1-12) if [ "$want" = "$(cat ~/.deps-hash 2>/dev/null)" ]; then echo "deps match snapshot ($want), skipping install" else echo "lockfiles changed, installing from the warm cache" (cd ~/web && npm ci --prefer-offline --no-audit --no-fund) (cd ~/api && uv sync --frozen) echo "$want" > ~/.deps-hash fi ``` When the lockfiles matched (three runs): ```text deps match snapshot (312bbcdb2bce), skipping install 0.00 s ``` When we made the saved hash stale, the fallback ran and took 21.3 seconds (one run), still faster than a cold install because the caches were in the snapshot: ```text lockfiles changed, installing from the warm cache … added 1455 packages in 21s Checked 77 packages in 0.74ms 21.3 s ``` If the fallback runs often, your snapshot is out of date. Run `deps-snapshot.sh` again from CI on the main branch so the next job gets a fresh one. ## When to rebuild | Change | Rebuild? | | - | - | | A lockfile changed (`package-lock.json`, `uv.lock`, `requirements.txt`, `pnpm-lock.yaml`) | Yes. The hash changes, so the script builds a new name. | | Only source code changed | No. Pull the new code in the job; `node_modules` and the venv still match. | | Your install step changed (a new apt package, another Node or Python version) | Yes. Pass `install.sh` as one more file to hash, so editing it also changes the name. | | The snapshot is old but the lockfiles match | Only if the install step fetches something no lockfile pins, such as `apt-get upgrade`. | ## What it costs A named snapshot has no separate charge. What it stores counts toward your plan's included storage, together with stopped sandboxes: 50 GB on the $20 plan. Storage is measured as the data beyond the shared OS image. In our build, `node_modules` was 1.4 GB, `~/.npm` 277 MB, the uv cache 253 MB and the pip cache 95 MB. That is why the script deletes older snapshots with the same prefix. See [Billing](/docs/billing) for the other plans. ## FAQ ### Should the snapshot hold node_modules or only the npm cache? Both. In our runs the installed `node_modules` saved all 23.8 seconds, and the cache alone saved about 6. The cache is the fallback for when the lockfile changes between snapshot builds. ### Does the snapshot keep running processes? No. A named snapshot saves the disk, so a sandbox started from it boots fresh. Enabled systemd services start; anything else you start in the job. ### Can I put API keys in the dependency snapshot? Keep them out. A named snapshot saves the whole disk, including any `.npmrc` or credentials file the build step wrote. Pass secrets at job time with `--environment` next to `--from`. ### Why hash the lockfile and not package.json? The lockfile pins every resolved version, so its hash changes exactly when the installed tree would. `package.json` or `pyproject.toml` can stay the same while resolved versions move. ### Is a fork faster than --from for this? Both copy a disk, so the dependencies come across either way. A fork needs its source sandbox to still exist; a named snapshot doesn't, which is why the script deletes the builder. ## Related - [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template) - [How fast does a cloud sandbox start, resume and fork?](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork) - [Run each GitHub Actions job in a fresh microVM](/guides/run-each-github-actions-job-in-a-fresh-microvm) - [Snapshots](/docs/snapshots) --- # How Do I Tell if a Sandbox Resume Restored Memory or Cold-Booted? Source: https://sandboxer.com/guides/detect-a-cold-boot-after-resume. Updated 2026-10-27. **Short answer:** Read memoryRestored after every resume. true means memory came back and processes kept running. false means the sandbox booted from disk, and resumeReason says why (cpu_mismatch, resized, no_memory_snapshot or restore_failed). Inside the guest, compare /proc/sys/kernel/random/boot_id with a saved copy. On a cold boot, restart services and re-warm caches. A resume usually restores the VM's memory, so your processes carry on (see [pause and resume with memory](/guides/pause-and-resume-a-sandbox-with-memory)). Sometimes it boots from disk instead. Your files are kept, but nothing is running. An agent that assumes its dev server, REPL or cache is still there will then fail in confusing ways. This page shows how to tell the two cases apart and what to do in each. ## The signals at a glance We ran both cases on a `small` sandbox on 2026-10-27: a plain `sbx stop --wait` + `sbx resume`, then a stop and `sbx resume --type default`. | Signal | Memory resume | Cold boot (resize) | | - | - | - | | `memoryRestored` (API, `sbx info --json`) | `true` | `false` | | `resumeReason` | `null` | `"resized"` | | CLI note on `sbx resume` (terminal) | none | `Resumed from disk: files are intact, running processes were not kept (resized).` | | `/proc/sys/kernel/random/boot_id` | unchanged | new value | | `/proc/uptime` | keeps counting (39.4 s) | starts again (2.8 s) | | Process started before the stop | alive, same PID | gone | | File in `/tmp` | still there | gone | | File in your home directory | still there | still there | | Resume time | 1.4 s | 3.4 s | `memoryRestored` is `null` before a sandbox's first resume, and on forks. When it's `false`, `resumeReason` is one of the four values in the [snapshots docs](/docs/snapshots#resume): `cpu_mismatch` (no machine with the sandbox's CPU type was free in time), `resized`, `no_memory_snapshot` or `restore_failed`. We could only force `resized` on prod. The other three depend on the platform's state, so treat any `false` the same way. ## Check from outside: the API or CLI The orchestrator that resumed the sandbox should check right away. With the CLI: ```bash sbx resume $id >/dev/null read restored reason < <(sbx info $id --json | jq -r '.sandbox | "\(.memoryRestored) \(.resumeReason // "none")"') echo "restored=$restored reason=$reason" ``` ```text restored=false reason=resized ``` With the REST API, the same two fields are on the sandbox object: ```bash curl -sS https://sandboxer.com/api/v1/sandboxes/$id \ -H "Authorization: Bearer $SANDBOXER_API_KEY" | jq -c '.sandbox | {memoryRestored, resumeReason}' ``` ```text {"memoryRestored":true,"resumeReason":null} ``` The `ready` event that `sbx resume --json` prints already carries the full sandbox, including `memoryRestored`, so a script that reads the event stream needs no extra call. A human-readable `sbx resume` prints a note only for a cold boot. It prints nothing extra when memory was restored. ## Check from inside: the boot id Code running in the sandbox can't see the API fields without a key, and it may not know a resume happened at all. The Linux kernel gives each boot a random id in `/proc/sys/kernel/random/boot_id`. A memory resume keeps the same kernel, so the id stays the same. A cold boot gets a new one. Save it and compare: ```bash mkdir -p ~/bin && cat > ~/bin/boot-check <<'EOF' #!/bin/sh # Prints warm or cold, then remembers this boot. now=$(cat /proc/sys/kernel/random/boot_id) last=$(cat ~/.last-boot-id 2>/dev/null) echo "$now" > ~/.last-boot-id if [ "$now" = "$last" ]; then echo warm; else echo cold; fi EOF chmod +x ~/bin/boot-check ``` Keep the saved copy in your home directory, not `/tmp`. In our cold boot, `/tmp` came back empty. What we saw across the three runs: ```text first run cold (no saved id yet) after sbx resume warm uptime 7.08 s, server PID 554 still listed after sbx resume --type default cold uptime 2.74 s ``` The ids themselves, from another run: ```text before stop: 45b7df5a-4d1f-4c77-932f-ce80599c9927 after memory resume: 45b7df5a-4d1f-4c77-932f-ce80599c9927 after resize resume: edfaa86e-d636-43c9-af19-f63aac2433d4 ``` Uptime is a weaker signal. A low uptime suggests a cold boot, but a sandbox that ran for only a few seconds before its stop also has a low uptime after a memory resume (7.08 s above). Use the boot id. ## What to do in each case | You see | What's still there | Do this | | - | - | - | | `memoryRestored: true` / same boot id | Processes, open files, in-memory caches, shell state | Nothing for processes. Re-check anything that depends on elapsed time (next section) | | `memoryRestored: false` / new boot id | Files in your home directory and on disk, enabled systemd units | Start what you started by hand (dev servers, REPLs, `tmux`), rebuild in-memory caches, rerun warm-up requests, and treat jobs that were running as lost | | `resumeReason: "cpu_mismatch"` and you need the processes | Same as above | Next time, resume with `sbx resume --require-memory`. It waits up to 30 minutes for a matching machine instead of booting from disk | An agent loop can make the restart step automatic: ```bash if [ "$restored" != true ]; then sbx exec $id --detach "python3 -m http.server 8000" # start your service again # then rerun your warm-up step, e.g. a first request or a cache build fi ``` A better fix is to need no restart step at all. Run long-lived services as enabled systemd units, so a cold boot starts them for you (see [services that should always run](/docs/long-running#services-that-should-always-run)). Then the check only decides whether to re-warm caches and redo lost work. `--require-memory` can't be combined with `--type`: a new size always boots from disk. The CLI's cold-boot note still suggests `--require-memory` after a `resized` resume. Ignore it in that case. Moving to a smaller size isn't supported yet (create a new sandbox and copy your files). Our `sbx resume --type small` after moving to `default` returned `400 type_too_small` ("The sandbox's data does not fit the small disk."). ## Timers after a memory resume The wall clock is corrected on resume, so `date` and `time.time()` are right. Elapsed-time clocks may not count all of the stopped time. In our test on 2026-10-27, a 2-minute stop moved the guest's monotonic clock forward by only about 62 seconds: ```text before stop real 1793097486.33 mono 3.46 boot 3.46 after resume real 1793097610.91 mono 65.17 boot 65.17 ``` The wall clock moved 124.6 s, and the monotonic clock (and `CLOCK_BOOTTIME`) 61.7 s. A `sleep 90` started just before the stop fired 152 seconds of wall time after it started, 28 seconds after the resume. In a shorter, 32-second stop, the monotonic clock moved about the same as the wall clock. Don't rely on either behavior. If a deadline, timeout or lease must hold across a stop, compare wall-clock timestamps (`date +%s`, `time.time()`) rather than monotonic ones, or recheck it when your boot check runs. ## FAQ ### Which is more reliable, memoryRestored or the boot id? Both told the truth in every run. Use `memoryRestored` from the orchestrator, because it also gives the reason. Use the boot id inside the sandbox, where you have no API key or don't know a resume happened. ### Can I force a cold boot to test my recovery code? Yes. Stop the sandbox, then resume it at a larger size with `sbx resume $id --type default`. It reports `memoryRestored: false` and `resumeReason: "resized"`. Moving to a smaller size isn't supported yet, so test on a throwaway sandbox. ### Do files survive a cold boot? Files on the disk do, including your home directory. `/tmp` was empty after our cold boot, so keep anything you need in your home directory. ### Does an idle auto-pause behave the same way? Yes. A wake after [idle auto-pause](/guides/idle-auto-pause-and-wake-on-use) is a resume, so it sets `memoryRestored` and `resumeReason` the same way. ## Related - [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory) - [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox) - [Persistent or ephemeral sandboxes for agents](/guides/persistent-or-ephemeral-sandboxes-for-agents) - [Snapshots and resume](/docs/snapshots) --- # How to Stream an Agent's Sandbox Output Live to a Web UI Source: https://sandboxer.com/guides/stream-sandbox-logs-to-a-web-ui. Updated 2026-10-27. **Short answer:** Start the command detached, attach to its output from your server with the SDK, and relay each chunk to the browser as Server-Sent Events. Use the byte offsets as event ids so a reconnect resumes with nothing lost or repeated. On prod, lines reached a Chrome page about 0.2 seconds after the sandbox printed them. ## The options, measured Sandboxer gives you five ways to read a command's output while it runs. We timed each one on prod on 2026-10-27 with a `small` sandbox in `eu-central`. The command printed a line with the sandbox's clock every 0.5 seconds, and the receiver noted when each line arrived. | Way to read output | API / CLI / SDK | Median delay | Worst seen | Survives a dropped connection | | - | - | - | - | - | | Streamed command | `POST /commands` with `stream: true`, `sbx exec --stream`, `execStream()` | 63–66 ms | 71 ms | No: the command is tied to the request, at most 600 s | | Exec WebSocket | `GET /exec/ws`, `sbx exec -i` / `-t`, `pty()` | 67 ms | 67 ms | No: built for interactive terminals | | Attach to a detached command | `GET /commands/{processId}/stream`, `sbx exec --attach`, `attach()` | 171–197 ms | 895 ms | Yes: reattach from any byte offset | | Poll status with offsets (every 1 s) | `GET /commands/{processId}?stdoutOffset=`, `status()` | 635 ms | 1,138 ms | Yes | | Follow command history | `sbx history --follow` | 1,237 ms | 2,098 ms | Yes, and works after the sandbox stops | Read the numbers this way: - About 63 ms of every row is the network. Our test machine had a 127 ms round trip to the sandbox, so one way is roughly 63 ms. The streamed command and the WebSocket add almost nothing on top. - Attach adds up to 200 ms because the sandbox reads the command's log file every 200 ms. The 895 ms outlier came from one `sbx exec --attach` run of 12 lines; the 80 lines through the SDK never went above 257 ms. - Polling adds about half the interval on average. `sbx history --follow` polls every 2 seconds, so use it to read a log, not to drive a UI. The SDK runs were 40 lines each, repeated twice for the streamed and attach rows; the CLI runs were 6 to 12 lines. ## Which one to use for a UI | Your case | Use | | - | - | | An agent tool call that finishes in under 10 minutes, and the agent waits for the result | `execStream()`, and forward each chunk as it comes | | A job the user watches, can reload the page on, or comes back to | `start()` + `attach()`, relayed over SSE (below) | | A terminal the user types into | The exec WebSocket (`sandbox.pty()`); see the [API reference](/docs/api) | | A serverless backend that can't hold a connection open | Poll `status()` with `stdoutOffset` every 1–2 s | | Showing output after the job ended, or after the sandbox stopped | Command history: see [run a long job and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox) | The detached path costs about 0.1 seconds more than a streamed command. In return, the command keeps running if your server restarts, and a viewer can attach to it from any byte offset. ## Build it: a relay from sandbox to browser Your API key must stay on your server, so the browser never talks to Sandboxer. A small relay holds the key, attaches to the command and re-emits each chunk as an SSE event. The event id is the pair of byte offsets (`stdout,stderr`). When the connection drops, the browser's `EventSource` sends that id back in the `Last-Event-ID` header, and the relay reattaches from exactly there. ```bash npm i sandboxer-sdk ``` ```javascript // relay.mjs: start a command in a sandbox and relay its output to the browser over SSE. // Run: SANDBOXER_API_KEY=... SANDBOX_ID=sb_... node relay.mjs import http from "node:http"; import { Sandboxer } from "sandboxer-sdk"; const sandbox = new Sandboxer().sandbox(process.env.SANDBOX_ID); const PORT = Number(process.env.PORT ?? 8787); const page = `
`;

http.createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");

  if (url.pathname === "/") {                       // the page: /?pid=123
    res.writeHead(200, { "content-type": "text/html" }).end(page);

  } else if (url.pathname === "/run" && req.method === "POST") {
    let cmd = "";
    for await (const chunk of req) cmd += chunk;
    const command = await sandbox.start(cmd);       // detached: returns at once
    res.writeHead(200, { "content-type": "application/json" })
       .end(JSON.stringify({ pid: command.processId, commandId: command.commandId }));

  } else if (url.pathname === "/stream") {
    const pid = Number(url.searchParams.get("pid"));
    // The SSE event id is "," in bytes. On reconnect the browser
    // sends it back as Last-Event-ID and the relay resumes there: nothing lost or repeated.
    const off = { stdout: 0, stderr: 0 };
    [off.stdout = 0, off.stderr = 0] = String(req.headers["last-event-id"] ?? "0,0").split(",").map(Number);
    res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
    const abort = new AbortController();
    req.on("close", () => abort.abort());
    try {
      const frames = sandbox.attach(pid, { stdoutOffset: off.stdout, stderrOffset: off.stderr, signal: abort.signal });
      for await (const f of frames) {
        if (f.type === "stdout" || f.type === "stderr") {
          off[f.type] = f.nextOffset ?? off[f.type] + Buffer.byteLength(f.data ?? "");
          res.write(`id: ${off.stdout},${off.stderr}\ndata: ${JSON.stringify({ s: f.type, d: f.data })}\n\n`);
        } else if (f.type === "exit") {
          res.write(`event: exit\ndata: ${f.exitCode}\n\n`);
        }
      }
    } catch (err) {
      if (!abort.signal.aborted) res.write(`event: error\ndata: ${JSON.stringify(String(err))}\n\n`);
    }
    res.end();

  } else {
    res.writeHead(404).end();
  }
}).listen(PORT, "127.0.0.1", () => console.log(`relay on http://127.0.0.1:${PORT}`));
```

The `/run` route is there so you can try the relay. In a product, your agent starts the command, and `/stream` must check that the signed-in user owns the sandbox before attaching. The page keeps the process id in its URL, so a reload or a shared link opens the same output.

## Try it with curl

Start a job that writes to both streams and exits with code 3:

```bash
curl -sS -X POST localhost:8787/run \
  --data 'for i in $(seq 1 6); do echo "step $i"; echo "warn $i" >&2; sleep 1; done; exit 3'
```

```json
{"pid":1195,"commandId":"cmd_gyxkawsuf66y"}
```

Watch it, then cut the connection after 2.5 seconds:

```bash
timeout 2.5 curl -sN "localhost:8787/stream?pid=1195"
```

```text
id: 7,0
data: {"s":"stdout","d":"step 1\n"}

id: 7,7
data: {"s":"stderr","d":"warn 1\n"}

id: 14,7
data: {"s":"stdout","d":"step 2\n"}
…
id: 21,21
data: {"s":"stderr","d":"warn 3\n"}
```

Two seconds later, reconnect the way a browser would, with the last id:

```bash
curl -sN -H "Last-Event-ID: 21,21" "localhost:8787/stream?pid=1195"
```

```text
id: 35,21
data: {"s":"stdout","d":"step 4\nstep 5\n"}

id: 35,35
data: {"s":"stderr","d":"warn 4\nwarn 5\n"}

id: 42,35
data: {"s":"stdout","d":"step 6\n"}

id: 42,42
data: {"s":"stderr","d":"warn 6\n"}

event: exit
data: 3
```

Lines written while nobody watched (steps 4 and 5) arrive in one chunk, and nothing from before the cut repeats. The job never noticed the disconnect: it runs in the sandbox, not in the relay.

## What we saw in a browser

We loaded the page in headless Chrome (Playwright) and recorded `Date.now()` for each SSE message. Over two runs of 40 lines, the delay from `echo` in the sandbox to the message in the page was a median of 166 and 204 ms, with a worst case of 297 ms. That is the attach delay plus a few milliseconds for the local relay.

Then we killed the relay 3 seconds into a 10-line job and started it again 2 seconds later. Chrome's `EventSource` reconnected by itself and sent `Last-Event-ID`. The page ended with `step 1` through `step 10`, each once, then `[exit 0]`.

## Things that delay output

- **Your program buffers its own output.** Python buffers `print` when output isn't a terminal. Through `sbx exec --stream`, `python3 -c` printing a line every 0.5 s delivered all 6 lines together when it exited, the first one 3.07 s late. With `python3 -u` each line arrived 66 ms after it was printed. Use `-u` or `PYTHONUNBUFFERED=1`, and the equivalent flush option in other languages.
- **The sandbox clock is not your clock.** To measure delay, we compared the sandbox's timestamps with ours. Our sandbox's clock was 1.18 s behind, and `timedatectl` reported `System clock synchronized: no`. We measured the offset over a private hosted port (the shortest of 40 round trips) and subtracted it. Don't compute delays from raw sandbox timestamps.
- **Long quiet periods.** The attach stream sends a `heartbeat` frame after 15 seconds with no output and ends with a `reconnect` frame after 10 minutes. The SDK's `attach()` reopens it at the right offsets for you, and hides heartbeats unless you pass `heartbeats: true`.

## FAQ

### Can the browser connect to Sandboxer directly?

Not safely. Every endpoint needs an API key, and a key in the page would let anyone who opens DevTools control your sandboxes. Keep the key on your server and relay the output, as above.

### Why SSE and not a WebSocket to the browser?

The output only flows one way, and `EventSource` reconnects by itself and sends the last event id back. That id maps directly onto the attach stream's byte offsets, so resuming takes one header. Use a WebSocket when the user also types input.

### Does the command stop if the user closes the tab?

No. A detached command keeps running in the sandbox until it exits or you kill it. Closing the tab only ends that viewer's attach stream.

### How do I show the output after the job ended?

Read command history. It keeps the output of commands run through the API for 90 days and works while the sandbox is stopped; the [long job guide](/guides/run-a-long-job-in-a-cloud-sandbox) shows `sbx history`.

## Related

- [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Long-running tasks: stream output, attach and history](/docs/long-running)
- [TypeScript SDK](/docs/sdk-typescript)
- [Give every user their own sandbox](/guides/give-every-user-their-own-sandbox)

---

# How Do I Cap What Agent Sandboxes Can Cost?

Source: https://sandboxer.com/guides/cap-what-agent-sandboxes-can-cost. Updated 2026-10-27.

**Short answer:** Stack the levers that exist. Per sandbox, pick a small size, a short TTL and an idle timeout, and delete it when the task ends. Per key, give agents scoped, expiring keys. Per account, your plan's concurrency and start limits, a prepaid balance without auto-refill and alerts set the ceiling. A watchdog deletes stragglers.

There is no "max dollars per sandbox" setting. What you get instead is a set of controls that each bound one dimension: how fast a sandbox burns time, how long it can run per start, how many can run at once, how many can start per day, and how much balance there is to spend. This page lists every one we could find in the [docs](/docs/billing) and the API, shows which ones we tested on prod, and links the watchdog that catches the rest. For the price tables themselves, see [sandbox cost tables](/guides/sandbox-cost-tables).

## Every lever, at a glance

| Lever | Scope | What it bounds | Set with | Stops a running sandbox? |
| - | - | - | - | - |
| Size | Sandbox | Burn rate: `small` is 0.5× `default` | `type` / `--type` | No |
| TTL (auto-stop) | Sandbox | Run time per start | `ttlSeconds` / `--ttl`, `sbx extend` | Yes, at `archiveAfter` |
| Idle timeout | Sandbox | Run time with nothing happening | `idleStopMinutes` / `--idle`, `sbx idle` | Yes, pauses it |
| Delete on finish | Sandbox | Everything after the task, plus storage | `sbx delete --yes`, `DELETE /sandboxes/{id}` | Yes |
| Scoped, expiring key | Key | Which actions an agent can take, and until when | `sbx api-key create --actions … --ttl …` | No |
| In-sandbox token | Sandbox | Code inside can't create, extend or delete sandboxes | Automatic (`SANDBOXER_TOKEN`) | No |
| Concurrency limit | Account | Sandboxes active at once (100 on the $20 plan) | Your plan | No, refuses new ones |
| Start limits | Account | Creates, resumes and forks per minute, hour, day | Your plan | No, refuses new ones |
| Prepaid balance, auto-refill off | Account | Total machine time you can spend | Billing page, `sbx billing autorefill` | Yes, after a 24-hour grace |
| Alerts | Account | Nothing: they notify you | Dashboard **Alerts** | No |
| Member caps | Organization | One member's sandboxes at once, and machine time per plan period | Organization owner (usage cap: API only) | No, refuses new ones |

We tested the per-sandbox levers and the in-sandbox token on prod. We did **not** change any account-level setting (plan, auto-refill, alerts, keys) on the shared test account; those rows come from the [billing](/docs/billing), [limits](/docs/limits), [API keys](/docs/api-keys) and [organizations](/docs/organizations) docs.

## The ceiling, as arithmetic

Prices come from the [pricing page](/pricing): `default` costs $0.036 an hour, and `small` costs $0.018 an hour. A dollar buys 100,000 seconds of `default` time.

**One sandbox.** Its worst case per start is TTL × hourly rate. A `small` sandbox with a 15-minute TTL: 0.25 h × $0.018 = $0.0045 per start. The same sandbox with `--no-auto-stop` and idle pause off can run the whole month: 730 h × $0.018 = $13.14.

**A runaway fleet, per hour.** Your concurrency limit caps how many run at once. On the $20 plan that's 100. If all 100 are `default`: 100 × $0.036 = $3.60 for each hour they all run. If you only ever create `small`: 100 × $0.018 = $1.80.

**A runaway fleet, per day.** Every create, resume, fork and wake on use counts as a start, and each start gets at most one TTL of run time. So, as long as nothing extends a sandbox or turns auto-stop off, run time per day is at most starts per day × TTL. On the $20 plan (200 starts a day) with a 1-hour TTL: 200 × 1 h = 200 h. At the `default` rate that's 200 × $0.036 = $7.20; for `small`, 200 × $0.018 = $3.60. Without TTLs the concurrency limit is the only bound: 100 × 24 h × $0.036 = $86.40 a day.

**The account.** With auto-refill off, the balance is the hard ceiling: your plan's included time plus any packs you bought. On the $20 plan that's 2,000,000 seconds of `default` time ($20 × 100,000), or 555 hours of `default`.

## Per sandbox: size, TTL, idle timeout

Set all three when you create the sandbox, so a sandbox the agent forgets about still stops. We created a `small` sandbox through the REST API with a 15-minute TTL, a 10-minute idle timeout and two labels to find it by later:

```bash
curl -sS -X POST https://sandboxer.com/api/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"agent-job-1","type":"small","ttlSeconds":900,"idleStopMinutes":10,
       "labels":{"job":"agent-job","tenant":"demo"}}'
```

```json
{
  "id": "sb_wp6oo0ktg2",
  "name": "agent-job-1",
  "state": "provisioning",
  "type": "small",
  "ttlSeconds": 900,
  "idleStopMinutes": 10,
  "archiveAfter": "2026-10-27T10:51:57.1552Z",
  "labels": { "job": "agent-job", "tenant": "demo" }
}
```

The response was `202` (trimmed with `jq`), and the sandbox was `ready` five seconds later. `archiveAfter` is creation time plus 900 seconds. The CLI equivalent is `sbx new --type small --ttl 900 --idle 10 --name agent-job-1`; labels are REST and MCP only ([labels](/docs/api#labels)).

Three details that change the arithmetic:

- **The TTL counts from each start, not from creation.** `sbx extend --ttl` sets the deadline to now + TTL, and a resume or a wake on use starts a fresh TTL window. A sandbox that idles, wakes, idles and wakes can run indefinitely in TTL-sized pieces. Only deleting it ends that.
- **Idle pause stops compute, not storage.** A paused sandbox bills no compute, but its disk and memory count toward your plan's [included storage](/docs/billing#storage). The idle defaults are 15 to 120 minutes depending on your plan; see [idle auto-pause](/guides/idle-auto-pause-and-wake-on-use) for what counts as activity.
- **`--no-auto-stop` removes the per-start bound.** We turned it on and back off on our sandbox:

```bash
sbx extend sb_wp6oo0ktg2 --no-auto-stop
sbx --json info sb_wp6oo0ktg2 | jq '.sandbox | {ttlSeconds, archiveAfter, idleStopMinutes}'
sbx extend sb_wp6oo0ktg2 --ttl 600
sbx --json info sb_wp6oo0ktg2 | jq '.sandbox | {ttlSeconds, archiveAfter, idleStopMinutes}'
```

```json
{ "ttlSeconds": null, "archiveAfter": null, "idleStopMinutes": 10 }
{ "ttlSeconds": 600, "archiveAfter": "2026-10-27T10:47:09.249674Z", "idleStopMinutes": 10 }
```

With auto-stop off, the idle timeout is the only thing left that stops it, and only when nothing is using it. A dev server polled by a health check, a `~/.sandboxer/keep-awake` file or a load average of 0.5 or more keeps it awake. If a sandbox needs `--no-auto-stop`, keep its idle timeout on and give it a label your watchdog skips.

## Per sandbox: delete when the task ends

A stopped sandbox bills no compute, but it still holds storage, and when storage is full new sandboxes answer `402 storage_limit_reached`. For agent work that leaves nothing worth keeping, delete in a `finally` block, so a crash in the tool call still cleans up:

```bash
sbx delete "$id" --yes
```

The REST call is `DELETE /sandboxes/{id}` with the header `X-Sandboxer-Confirm-Delete` set to the sandbox id. [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call) shows the create-run-delete loop with timings. A watchdog catches the ones a `finally` misses: a killed process, a lost network, a deploy mid-task.

## Per key: scoped, expiring keys

API keys can't carry a dollar limit or a TTL ceiling, but they can limit which actions a caller may take and expire on their own. Two patterns from the [API keys docs](/docs/api-keys):

```bash
# The backend that runs agents: create, run, delete only what it created; expires in 30 days
sbx api-key create agent-runner --preset ci --ttl 30d
# The watchdog: read and delete, nothing else
sbx api-key create sandbox-watchdog --actions sandbox.read,sandbox.delete --ttl 90d
```

We didn't run these: creating keys changes the shared test account. The `ci` preset includes `sandbox.delete-own`, which deletes only sandboxes that key created. A key without `sandbox.create`, `sandbox.resume` and `sandbox.fork` can't start anything, so it can't add cost at all.

A key with `sandbox.create` can still ask for `"ttlSeconds": null` or `xlarge`. If an agent picks the arguments, don't give it the key. Have your backend create sandboxes with the size and TTL you choose, and expose only "run this command" to the model. The MCP server's `sbx mcp --type small --ttl 1800` sets defaults for sandboxes the agent creates without those arguments; it is not a ceiling.

### The token inside the sandbox can't add cost

Code running in a sandbox gets `SANDBOXER_TOKEN`, a key limited to that sandbox. We tried to use it from inside our sandbox to create a `large` sandbox and to turn off its own auto-stop:

```bash
sbx exec sb_wp6oo0ktg2 -- bash -c 'curl -sS -X POST https://sandboxer.com/api/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"type\":\"large\"}"'
```

```json
{"code":"api_key_action_forbidden","error":{"code":"api_key_action_forbidden","details":{"action":"sandbox.create"},"message":"This API key does not allow sandbox.create.","status":403}, …}
```

The same token sending `PATCH /sandboxes/sb_wp6oo0ktg2` with `{"ttlSeconds":null}` got `403 api_key_action_forbidden` with `"action":"sandbox.update"`. An agent with a shell can't spawn more sandboxes or keep its own running past the TTL, as long as you don't put a broader key in its environment.

## Per account: limits, balance, alerts

None of these were changed in our test. They come from the docs and the API.

- **Concurrency and start limits.** Your plan sets how many sandboxes can be active at once and how many starts you get per minute, hour and day. Going over returns `429 limit_reached`, `rate_limited` or `daily_limit_reached`. Check what's left with `sbx limits --json | jq '.starts'`. See [rate limits and concurrency](/docs/limits).
- **Prepaid balance with auto-refill off.** This is the closest thing to a hard spend cap. When the balance reaches zero, new creates, resumes and forks fail with `402 billing_required`, the account owner gets an email, and running sandboxes keep running for a 24-hour grace period, then stop with memory and disk kept. With auto-refill on, packs are bought automatically below your threshold, so there is no ceiling. Check it with `sbx billing`.
- **Alerts.** The dashboard's **Alerts** page has four rules: balance falls below N hours, spend in the last 24 hours (rolling, at list price) exceeds N dollars, a sandbox runs longer than N hours, and start-limit usage reaches N% of a limit (checked against the per-minute, per-hour and per-day start limits). Each sends an email or a webhook, and each rule fires at most once per 24 hours per subject. Alerts notify only; nothing is stopped.
- **Organization member caps.** An organization owner can cap a member's concurrent sandboxes (`429 member_limit_reached`) and a member's machine time with the API: `PATCH /orgs/{orgId}/members/{userId}` with `usageCapSeconds`, counted in machine-time seconds from the start of the plan period (or over the last month without a plan). Once it's reached, the member's creates, resumes and forks get `402 team_member_cap_reached`; running sandboxes keep running. `clearUsageCap: true` removes it. The usage cap is API-only ([organizations](/docs/organizations)). Giving each agent service its own member is a way to give it its own budget.

## A watchdog that deletes stragglers

The levers above bound the damage; a watchdog removes what's left. Run it from cron with a server-side label filter, a dry run by default, and an age threshold a bit above your longest real task. [Clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes) has the full script, with the label query, paging and the confirmation header a delete needs.

We ran a small Python version against our sandbox, filtered by its label and name prefix. A 60-minute threshold matched nothing, a 0.5-minute one matched it, and the `--delete` run removed it:

```text
would delete sb_wp6oo0ktg2 agent-job-1 ready small age=0.6m
deleted sb_wp6oo0ktg2 agent-job-1 ready small age=0.7m
```

`sbx --json info sb_wp6oo0ktg2` answered `404 not_found` (exit code 4) in the same second. For cost, two choices matter:

- **Delete or stop.** Deleting also frees storage. To keep the data, list with `state=ready,idle,running` and call `POST /sandboxes/{id}/stop` instead.
- **Give it its own key.** A key with only `sandbox.read` and `sandbox.delete` can't start anything, so the watchdog itself can't add cost.

Keep the watchdog as the backstop. The primary path is still deleting in your own code when the task ends.

## What our test cost

`sbx usage` read the meter while the sandbox ran: 19 seconds of `default` time after 40 seconds of wall time, because `small` runs at 0.5×. That's 19 / 100,000 = $0.00019. The whole test, including the in-sandbox calls, ran from 10:36:57 to 10:37:37 UTC on October 27, 2026, with no errors from the platform.

## FAQ

### Is there a hard dollar limit per account?

Not as a separate setting. The prepaid balance is the hard ceiling when auto-refill is off: new starts stop at zero and running sandboxes stop after a 24-hour grace period. Alerts can warn you before that.

### Does the TTL limit a sandbox's total lifetime?

No. It limits each run. A resume, a wake on use or `sbx extend` sets a new deadline of now + TTL. To end a sandbox for good, delete it.

### Can an agent inside a sandbox start more sandboxes?

Not with the token it gets. We tried from inside: `SANDBOXER_TOKEN` got `403 api_key_action_forbidden` for `sandbox.create` and for changing its own TTL. Don't put a broader key in the sandbox's environment.

### Does a stopped or paused sandbox cost anything?

No compute. Its disk and memory count toward your plan's included storage, and when that's full new sandboxes are refused until you delete some.

### Can I give one API key its own budget?

No: keys limit actions, sandboxes, environments and expiry, not spend. For a separate budget, use an organization member with a usage cap, or enforce one in your backend with `sbx usage` per sandbox.

## Related

- [How much does a cloud sandbox cost?](/guides/sandbox-cost-tables)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [API keys and scopes](/docs/api-keys)
- [Limits and error codes](/docs/limits)

---

# How Do I Find and Clean Up Orphaned Sandboxes?

Source: https://sandboxer.com/guides/clean-up-orphaned-sandboxes. Updated 2026-10-28.

**Short answer:** Label every sandbox at create with an owner and run id, then list by that label (GET /sandboxes?labels=…), check state and lastActivityAt, and delete with a dry run first. A TTL and idle pause stop forgotten sandboxes but keep their storage. Deleting removes automatic snapshots and hosted URLs; named snapshots stay.

An orphan is a sandbox whose creator is gone: an agent that lost its context, a job killed mid-run, a `finally` block that never ran. You can only clean up what you can identify, so the work starts at create time. This page covers tagging, finding, the backstops that limit the damage, a cleanup script we ran on prod, and exactly what a delete removes. For the cost levers (TTLs, scoped keys, account limits and alerts), see [cap what agent sandboxes can cost](/guides/cap-what-agent-sandboxes-can-cost).

## Tag every sandbox when you create it

Give each sandbox two handles: a `name` for people and `labels` for code. Labels are set through the REST API and the [MCP server](/docs/mcp); the CLI and SDKs don't have a labels flag yet ([labels docs](/docs/api#labels)). Use one label that says which app or agent owns it and one that names the run, so a crashed run's sandboxes are one query away.

```bash
curl -sS -X POST https://sandboxer.com/api/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
  -d '{"type":"small","name":"orphan-demo-1","ttlSeconds":1800,
       "labels":{"owner":"myapp-251bf53f","job":"run-1"}}'
```

```json
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"provisioning","labels":{"job":"run-1","owner":"myapp-251bf53f"},"archiveAfter":"2026-10-27T11:08:40.832437Z"}
```

That came back `202` (trimmed with `jq`). If a sandbox was made with `sbx new`, add labels afterwards with `PATCH`. A `PATCH` replaces the whole label set, so send every label you want to keep:

```bash
sbx new --name orphan-demo-2 --type small --ttl 1800
curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/sb_y6da8q95pz \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
  -d '{"labels":{"owner":"myapp-251bf53f","job":"run-2"}}'
```

```json
{"id":"sb_y6da8q95pz","labels":{"job":"run-2","owner":"myapp-251bf53f"}}
```

Make the owner value unique to your app. If several services or people share an account, a generic value like `owner=agent` will match their sandboxes too.

## Find them

`GET /sandboxes` filters on the server by `labels` and `state`, and pages with `limit` and `cursor` ([pagination](/docs/api#pagination)). We stopped the second sandbox first, so the list shows one of each:

```bash
curl -sS -G https://sandboxer.com/api/v1/sandboxes -H "Authorization: Bearer $SANDBOXER_API_KEY" \
  --data-urlencode "labels=owner=myapp-251bf53f" \
  | jq -c '.sandboxes[] | {id,name,state,createdBy,lastActivityAt,archiveAfter}'
```

```json
{"id":"sb_y6da8q95pz","name":"orphan-demo-2","state":"archived","createdBy":"…","lastActivityAt":"2026-10-27T10:40:16.659896Z","archiveAfter":null}
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"ready","createdBy":"…","lastActivityAt":"2026-10-27T10:40:38Z","archiveAfter":"2026-10-27T11:08:40.832437Z"}
```

Variations we ran:

| Query | What it matched |
| - | - |
| `labels=owner=…` | Both sandboxes, running and stopped |
| `labels=owner=…` and `state=archived` | Only the stopped one (its `stop.reason` was `user`) |
| `labels={"owner":"…","job":"run-1"}` (JSON form) | Only `sb_xqxi33l1ve`: every label must match |
| `labels=owner` (no value) | `400 invalid_labels` |

The fields that tell you whether a sandbox is abandoned:

| Field | What it tells you |
| - | - |
| `labels` | Which app and run made it. Filtered on the server |
| `name` | A display name. Not unique and not filterable on the server; match it with `jq` |
| `state` | `ready` and similar are billing compute. `archived` is stopped: no compute, but it holds storage |
| `lastActivityAt` | The last command, file call, hosted request or start. Recorded in batches, so it can lag by a little |
| `archiveAfter` | When auto-stop fires. On a running sandbox, `null` means auto-stop is off. Ours read `null` after we stopped it, so check `state` first |
| `createdBy`, `createdById` | Which member created it. Useful with an organization wallet, where the list also includes the organization's sandboxes |

There is no "created by this API key" field in the list. `GET /api-keys/{id}/usage` lists the sandboxes a key created ([API keys](/docs/api-keys)).

Without labels, fall back to names. `sbx list` shows only running sandboxes by default, so a stopped orphan is invisible unless you pass `--all`:

```bash
sbx list --all --json | jq -c '.sandboxes[]
  | select(.name // "" | startswith("orphan-demo-")) | {id,name,state}'
```

```json
{"id":"sb_y6da8q95pz","name":"orphan-demo-2","state":"archived"}
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"ready"}
```

## The backstops: TTL and idle pause

Two settings stop a forgotten sandbox without anyone noticing it. Neither deletes it.

| Backstop | Default | What it does to an orphan | What it leaves behind |
| - | - | - | - |
| Auto-stop (`ttlSeconds`, deadline in `archiveAfter`) | 1 hour; up to 30 days, or off with `--no-auto-stop` | Stops it at the deadline | Disk and memory, counted against your included storage |
| Idle pause (`idleStopMinutes`) | 15 to 120 minutes by plan | Pauses it when nothing touches it | The same, and the next request wakes it |

So an orphan with both left on costs compute for at most its TTL, then sits stopped. The one to hunt is a running sandbox with `archiveAfter: null`: auto-stop is off, and only idle pause can stop it. A dev server polled by a health check never goes idle. [Idle auto-pause](/guides/idle-auto-pause-and-wake-on-use) lists what counts as activity, and [sandbox cost tables](/guides/sandbox-cost-tables) shows how much storage each plan includes. When storage is full, new sandboxes answer `402 storage_limit_reached`, which is how stopped orphans eventually hurt.

## A cleanup script with a dry run

This script deletes the sandboxes that carry a label set. It is a dry run unless you pass `--delete`, it requires a label filter, and `--idle-minutes` keeps sandboxes that were active recently, using `lastActivityAt` rather than creation time. It collects every page before deleting, so the cursor doesn't shift under it. It needs `curl` and `jq` and reads `SANDBOXER_API_KEY` from the environment.

```bash
#!/usr/bin/env bash
# Delete the sandboxes that carry a label, after a dry run.
#   ./cleanup.sh owner=nightly-agent                      # dry run: print what would go
#   ./cleanup.sh owner=nightly-agent --idle-minutes 120   # only those quiet for 2 h
#   ./cleanup.sh owner=nightly-agent --delete             # really delete
set -euo pipefail
API=${SANDBOXER_API_URL:-https://sandboxer.com/api/v1}
LABELS=${1:?usage: cleanup.sh k=v[,k=v] [--idle-minutes N] [--delete]}; shift
IDLE=0; DELETE=0
while [ $# -gt 0 ]; do
  case $1 in
    --idle-minutes) IDLE=$2; shift 2 ;;
    --delete) DELETE=1; shift ;;
    *) echo "unknown flag: $1" >&2; exit 2 ;;
  esac
done
auth=(-H "Authorization: Bearer $SANDBOXER_API_KEY")
cutoff=$(( $(date +%s) - IDLE * 60 ))

# 1. Collect every match first (deleting while paging would shift the cursor).
targets=(); cursor=""
while :; do
  page=$(curl -sS --fail-with-body -G "$API/sandboxes" "${auth[@]}" \
    --data-urlencode "labels=$LABELS" --data-urlencode "limit=200" \
    ${cursor:+--data-urlencode "cursor=$cursor"})
  while IFS=$'\t' read -r id name state last; do
    [ -n "$id" ] || continue
    printf '%s\t%s\t%s\tlast activity %s\n' "$id" "$name" "$state" "$last"
    targets+=("$id")
  done < <(jq -r --argjson cutoff "$cutoff" '.sandboxes[]
      | (.lastActivityAt // .createdAt) as $t
      | select(($t | sub("\\.[0-9]+"; "") | fromdate) <= $cutoff)
      | [.id, (.name // ""), .state, $t] | @tsv' <<<"$page")
  [ "$(jq -r .pageInfo.hasMore <<<"$page")" = true ] || break
  cursor=$(jq -r .pageInfo.nextCursor <<<"$page")
done

echo "${#targets[@]} sandbox(es) match labels=$LABELS, idle >= ${IDLE} min"
if [ "$DELETE" != 1 ]; then echo "dry run: nothing deleted (add --delete)"; exit 0; fi

# 2. Delete. The API wants the id echoed back in a header as confirmation.
for id in "${targets[@]}"; do
  curl -sS --fail-with-body -X DELETE "$API/sandboxes/$id" "${auth[@]}" \
    -H "X-Sandboxer-Confirm-Delete: $id" | jq -c '{id: .operation.targetId, op: .operation.id, status: .operation.status}'
done
```

We ran it on October 27, 2026 against our two labeled sandboxes, about two minutes after creating them. First two dry runs:

```bash
./cleanup.sh owner=myapp-251bf53f --idle-minutes 60
./cleanup.sh owner=myapp-251bf53f
```

```text
0 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 60 min
dry run: nothing deleted (add --delete)
sb_y6da8q95pz	orphan-demo-2	archived	last activity 2026-10-27T10:40:16.659896Z
sb_xqxi33l1ve	orphan-demo-1	ready	last activity 2026-10-27T10:40:38Z
2 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 0 min
dry run: nothing deleted (add --delete)
```

Then the real run:

```bash
./cleanup.sh owner=myapp-251bf53f --delete
```

```text
…
2 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 0 min
{"id":"sb_y6da8q95pz","op":"bdop_4ed678ff9a4d98e6fcd67b174dad9083","status":"pending"}
{"id":"sb_xqxi33l1ve","op":"bdop_c2dd198e6821c56d8f4f89730f773d91","status":"pending"}
```

Both deletion operations read `completed` when we checked them a few seconds later with `sbx deletion status `, about 0.3 seconds after they were requested. A third dry run matched 0 sandboxes.

Without the `X-Sandboxer-Confirm-Delete` header, the API refuses with `409 confirmation_required` ([limits and errors](/docs/limits)). The CLI form of one delete is `sbx delete  --yes`.

## What deletion removes, and what it keeps

Before deleting, we put a private hosted URL on `sb_xqxi33l1ve` (a Python web server on port 3000) and saved `sb_y6da8q95pz` as a named snapshot. Then we checked everything after the script ran:

| Thing | Before | After the delete |
| - | - | - |
| `GET /sandboxes/{id}` | `200` | `404` for both; `sbx info` exits 4 with `not_found` |
| Hosted URL on port 3000 | `401` without the token, `200` with it | `404`, with the token and with the session cookie |
| Automatic snapshots (`sbx snapshots `) | One, kind `base` | The list answers `404`: the sandbox is gone, and so are the snapshots only it used |
| Named snapshot saved from it | `ready` | Still `ready`, `sourceSandboxId` pointing at the deleted id |
| Label query | 2 matches | 0 matches |

The [CLI docs](/docs/cli) put the snapshot rule this way: delete removes the sandbox and the snapshots only it uses. Snapshots that a fork or a named snapshot still depends on are kept until nothing uses them ([snapshots](/docs/snapshots)). Named snapshots are kept until you delete them with `sbx snapshot rm `. That makes them the other place orphans hide: they count toward included storage and are filterable by the same `labels` on `GET /named-snapshots`. We removed ours with `sbx snapshot rm` and confirmed it then answered `snapshot_not_found`.

There's no undo. If you might want the data, stop the sandbox instead and delete it later.

## FAQ

### Does auto-stop delete a sandbox?

No. It stops it at `archiveAfter` with memory and disk kept. A stopped sandbox bills no compute but counts toward your included storage until you delete it or its data is deleted, 14 days after it last stopped. We email you before that.

### Can I filter sandboxes by name on the server?

No. `GET /sandboxes` filters by `labels` and `state`. Filter names client-side with `jq`, and remember `sbx list` needs `--all` to show stopped sandboxes.

### How do I keep my cleanup from deleting someone else's sandboxes?

Filter by a label value only your app sets, run the dry run first, and read its list. Never delete by age alone on an account that other people or services share.

### Can an agent's key delete only the sandboxes it created?

Yes. A key with `sandbox.delete-own` instead of `sandbox.delete` gets `403` on sandboxes it didn't create. The `ci` preset includes it ([API keys](/docs/api-keys)).

### What happens to a sandbox's hosted URLs when it's deleted?

They stop answering. Ours returned `404` right after the delete, with or without the port's token.

## Related

- [Cap what agent sandboxes can cost](/guides/cap-what-agent-sandboxes-can-cost)
- [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call)
- [Does a cloud sandbox pause itself when idle?](/guides/idle-auto-pause-and-wake-on-use)
- [API: labels](/docs/api#labels)

---

# How Does an Agent Know a Service in a Sandbox Is Ready?

Source: https://sandboxer.com/guides/wait-for-a-port-to-be-ready-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Poll the real request, not the port. Inside the sandbox, loop curl against a health URL with a timeout. From outside, poll the hosted URL and ignore Sandboxer's own pages (they end in "Sandboxer · HTTP" and the status): 502 means nothing is listening yet. In our test, Grafana's port opened at 0.4 s but answered at 3.1 s.

An agent that starts a server and uses it in the next step needs a reliable "it's up" signal. `sbx new` and `sbx resume` only return once the sandbox itself is ready, so the gap is your app's own startup time. Everything below ran on prod on 2026-10-27, on a `small` sandbox (2 vCPU, 4 GB) with Grafana 12.2.0 in Docker as the real app. Tokens are replaced with ``.

## At a glance

| Check | Says ready when | Trust it? |
| - | - | - |
| `ss -ltn` / `nc -z` on the port | A socket is listening | No. Docker's port forward listened at 0.4 s; Grafana answered at 3.1 s |
| `systemctl is-active` | The process started (`Type=simple`) | No. `active` while curl still failed |
| `curl -f` on a health URL, inside | The app served a 2xx | Yes. Use this inside the sandbox |
| Hosted URL, outside | A response that isn't a Sandboxer page | Yes. Use this from your agent's side |
| `systemctl start` with an `ExecStartPost=` wait | Your own check passed | Yes. `start` blocks until ready |

## Inside the sandbox: probe with a request

We pulled the image first (10.5 s), so the timings below are startup only. This script starts Grafana bound to `127.0.0.1` and times each signal:

```bash
ms() { echo $(( ($(date +%s%N) - T0) / 1000000 )); }
T0=$(date +%s%N)
docker run -d --name grafana --restart unless-stopped -p 127.0.0.1:3000:3000 grafana/grafana:12.2.0 >/dev/null
echo "docker run returned: $(ms) ms"
timeout 120 bash -c 'until ss -ltnH "sport = :3000" | grep -q .; do sleep 0.1; done' && echo "port 3000 listening (ss): $(ms) ms"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1:3000/api/health); echo "curl right then: HTTP $code"
timeout 120 bash -c 'until curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:3000/api/health; do sleep 0.2; done' && echo "/api/health 200: $(ms) ms"
```

We sent it from our machine with `sbx exec -i $id "bash -s" < inside.sh`:

```text
docker run returned: 371 ms
port 3000 listening (ss): 384 ms
curl right then: HTTP 000
curl: (52) Empty reply from server
curl: (56) Recv failure: Connection reset by peer
…
/api/health 200: 3132 ms
```

The port was "open" for almost three seconds before anything could answer, because Docker's port forward listens as soon as the container starts. A port check would have handed the agent a dead endpoint. Three rules make the loop safe:

- Wrap it in `timeout`, so a crashed app fails the step instead of hanging it.
- Give curl `--max-time`, so one stuck connection can't eat the whole budget.
- Use `-f`, so an app's own `500` during warm-up counts as not ready.

## systemd: make `start` mean ready

A `Type=simple` unit reports `active` as soon as its process exists. Add an `ExecStartPost=` that waits, and `systemctl start` blocks until your check passes. We ran two units that both take three seconds to start serving:

```ini
# /etc/systemd/system/slow-b.service
[Service]
ExecStart=/bin/bash -c 'sleep 3; exec python3 -m http.server 8091 --bind 127.0.0.1'
ExecStartPost=/usr/bin/timeout 60 /bin/bash -c 'until curl -fs -o /dev/null http://127.0.0.1:8091/; do sleep 0.2; done'
```

```text
slow-a: start returned 15 ms, is-active=active, curl=000
slow-b: start returned 3116 ms, is-active=active, curl=200
```

`slow-a` is the same unit without the `ExecStartPost=` line. If the wait times out, the start fails and systemd marks the unit failed, which is the error you want an agent to see.

## From outside: read the hosted URL correctly

When your agent runs outside the sandbox, poll the [hosted URL](/guides/give-a-sandbox-port-an-https-url). Before your app answers, the Sandboxer proxy answers for it. Its pages are small HTML documents with a `` and a last line of `Sandboxer · HTTP <code>`, so you can tell them apart from your app's own errors. These are the ones we got, with Grafana not yet started and port 3000 hosted with `--localhost`:

| Status | Title | What it means | What to do |
| - | - | - | - |
| `401` | Access token required | Private URL, no or wrong `_token` | Fix the URL. Retrying won't help |
| `502` | Nothing is answering | The route works but nothing accepted the request on that port | Keep polling: the app is still starting, crashed, or bound to `127.0.0.1` without `--localhost` |
| `404` | Port not hosted | The sandbox exists but this port isn't hosted | Run `sbx host`. A new route can also 404 for a second or two |
| `404` | Not found | No running sandbox uses this address. We also got this from a stopped sandbox | Check the state with `sbx info`; resume it if stopped |
| `503` | Waking this sandbox… | An [idle-paused](/guides/idle-auto-pause-and-wake-on-use) sandbox is resuming | Retry after the `Retry-After` seconds |

A private URL checks the token before it proxies, so without the token you get `401` whether or not your app is up. Any status that doesn't carry the `Sandboxer · HTTP` line came from your app, even a `502` from an nginx inside it.

## A reusable wait function in bash

This waits until the app itself answers through the hosted URL. It keeps a cookie jar so the private link's first `302` (which sets the auth cookie) is followed like a browser would.

```bash
# wait_for_url URL [TIMEOUT_SECONDS]
# Returns 0 once the app itself answers (any status), 1 on timeout,
# 2 on a Sandboxer 401 (bad token), 3 if the sandbox is stopped.
wait_for_url() {
  local url=$1 timeout=${2:-120} jar body code title start
  jar=$(mktemp) body=$(mktemp) start=$(date +%s%N)
  while (( ($(date +%s%N) - start) / 1000000000 < timeout )); do
    code=$(curl -sL -b "$jar" -c "$jar" -o "$body" --max-time 10 \
      -w '%{http_code}' "$url") || code=000
    title=""
    if grep -q 'Sandboxer · HTTP' "$body"; then  # a page from the proxy, not the app
      title=$(sed -n 's:.*<title>\(.*\).*:\1:p' "$body")
      case "$code $title" in
        "401 "*) echo "401 $title" >&2; rm -f "$jar" "$body"; return 2 ;;
        "503 Sandbox is not running") echo "stopped: resume it" >&2; rm -f "$jar" "$body"; return 3 ;;
      esac            # 404 route not live yet, 502 nothing listening, 503 waking: retry
    elif [[ $code != 000 ]]; then
      echo "ready in $(( ($(date +%s%N) - start) / 1000000 )) ms (HTTP $code)"
      rm -f "$jar" "$body"; return 0
    fi
    echo "  $(( ($(date +%s%N) - start) / 1000000 )) ms: $code $title" >&2
    sleep 1
  done
  echo "timed out after ${timeout}s (last: $code $title)" >&2
  rm -f "$jar" "$body"; return 1
}
```

We started it on our machine half a second before sending the Grafana start script above:

```bash
wait_for_url "https://alert-fluent-lemon-3000.box.sandboxer.com?_token=" 120
```

```text
  331 ms: 502 Nothing is answering
  1664 ms: 502 Nothing is answering
  3003 ms: 502 Nothing is answering
  4342 ms: 502 Nothing is answering
ready in 6007 ms (HTTP 200)
```

That 6 s includes the `sbx exec` round trip that launched the container. Pass a health path (`…/api/health?_token=`) if you need a 2xx rather than "the app answered".

## The same in Python

Standard library only. `urllib` follows the redirect and the cookie processor keeps the auth cookie.

```python
import http.cookiejar, re, sys, time, urllib.error, urllib.request

def wait_for_url(url: str, timeout: float = 120.0) -> tuple[float, int]:
    """Block until the app behind a hosted URL answers. Returns (seconds waited, status)."""
    opener = urllib.request.build_opener(
        urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
    start = time.monotonic()
    last = None
    while time.monotonic() - start < timeout:
        try:
            with opener.open(url, timeout=10) as r:   # follows the 302 that sets the cookie
                return time.monotonic() - start, r.status
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", "replace")
            if "Sandboxer · HTTP" not in body:         # the app's own 4xx/5xx: it is up
                return time.monotonic() - start, e.code
            title = re.search(r"(.*?)", body).group(1)
            if e.code == 401 or title == "Sandbox is not running":
                raise RuntimeError(f"{e.code} {title}") from None
            last = f"{e.code} {title}"                 # 404 / 502 / 503 waking: retry
        except (urllib.error.URLError, TimeoutError) as e:
            last = str(e)
        time.sleep(1)
    raise TimeoutError(f"not ready after {timeout}s (last: {last})")

if __name__ == "__main__":
    secs, status = wait_for_url(sys.argv[1])
    print(f"ready in {secs:.1f} s (HTTP {status})")
```

```text
$ python3 wait_for_url.py "https://alert-fluent-lemon-3000.box.sandboxer.com?_token="
ready in 0.6 s (HTTP 200)
$ python3 wait_for_url.py "https://alert-fluent-lemon-3000.box.sandboxer.com/"
RuntimeError: 401 Access token required
```

## After create, after resume, after a cold boot

| Event | Sandbox ready | App answered on the hosted URL | Why |
| - | - | - | - |
| `sbx new --type small` | 3.2 s | When you start it (3.1 s for Grafana) | Nothing runs until you start it |
| `sbx resume`, `memoryRestored: true` | 1.3 s | 0.65 s later (2.0 s in total), first poll | Memory restored: Grafana never stopped |
| `sbx resume --type default`, `memoryRestored: false` | 3.4 s | 0.66 s later (4.0 s in total) | Booted from disk; Docker's `--restart unless-stopped` started Grafana 1 s after boot |

After the cold boot, both systemd test units were `inactive`: we had started them but never enabled them, so nothing brought them back. A cold boot only restarts what is set to start on boot, such as an enabled systemd unit or a container with a restart policy. Check `memoryRestored` in the `sbx resume` output (see [pause and resume](/guides/pause-and-resume-a-sandbox-with-memory)), and run your wait function either way: it costs one request when the app is already up.

While the sandbox was stopped, its hosted URL returned `404 Not found` with and without the token. The bash function keeps retrying on that until its timeout, so have your agent resume the sandbox before it waits.

## FAQ

### Why does my health check pass but requests still fail?

You're probably checking the port, not the app. A listening socket can belong to Docker's port forward, a relay or a process still loading. Check a real request instead, and include a health path if the app has one.

### How do I tell a Sandboxer 502 from my app's 502?

Sandboxer's error pages end with `Sandboxer · HTTP 502` and have the title "Nothing is answering". A 502 without that line came from a server inside your sandbox, such as an nginx whose upstream is down.

### Why does my private URL return 401 even though the app is running?

The proxy checks the token before it forwards anything, so a missing or wrong `_token` always gets 401. Use the full URL from `sbx host`, and keep cookies between requests so the first `302` sets the auth cookie.

### How long should the timeout be?

Measure your app once and give it a few times that. Grafana needed about 3 s on a `small` sandbox; a database restoring data or a dev server compiling will need longer. The point of the timeout is to fail the agent's step with a clear error rather than hang.

### Do I need to wait again after a resume?

Usually it returns at once: with memory restored, the app never stopped. After a cold boot (a size change, or no machine with the same CPU type), the app starts again only if it's set to start on boot, so wait for it.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Pause and resume a sandbox with its memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Hosting docs](/docs/hosting)

---

# How to Handle Sandboxer Rate Limits and Concurrency Caps

Source: https://sandboxer.com/guides/handle-rate-limits-and-concurrency-caps. Updated 2026-10-27.

**Short answer:** Size a client-side semaphore to your plan's free sandbox places from GET /limits, pace starts under the per-minute limit, and branch on the error code. Wait for Retry-After on rate_limited, queue on limit_reached, try the next size on no_ready_machine or capacity_exceeded, back off with jitter on 5xx, and give up on other 4xx.

An agent platform starts sandboxes in bursts: a user kicks off 40 tasks, or a planner fans out one sandbox per tool call. Sandboxer has three kinds of limits in the way: how many sandboxes may be active at once, how many starts per minute, hour and day, and how many API requests per minute. This page shows how to stay under them on the client, and what to do with each refusal. It ends with a Python and a bash helper that we tested against a mock API and then on production.

The full list of codes is in [Sandboxer API error codes](/guides/sandboxer-api-error-codes), and replaying creates is covered in [Retry sandbox API calls safely with idempotency keys](/guides/retry-sandbox-api-calls-safely-with-idempotency-keys). This page doesn't repeat them. It's about the scheduling around them.

One caveat up front. Our test account is an internal account, and internal accounts skip the start windows and get a raised concurrency cap. So we could not trigger `limit_reached`, `rate_limited` or `daily_limit_reached` on production. Every limit, code, header and default below comes from the API source, the [limits docs](/docs/limits) and the pricing table, as of 2026-10-27. The refusal paths of both helpers were tested against a mock that returns those exact codes and headers.

## The limits you're designing for

| Limit | Value (from source) | Refusal | What comes with it |
| - | - | - | - |
| Active at once | $20/month 100, $100 300, $500 1,000, $2,000 2,000 | `429 limit_reached` | `details.limit`, `used`, `window: "concurrent"`. No `Retry-After` |
| Org member cap | Set by an org owner | `429 member_limit_reached` | No `Retry-After` |
| Starts per rolling minute | $20 12, $100 30, $500 65, $2,000 90 | `429 rate_limited` | `Retry-After` header, `details.window: "minute"`, `retryAfterSeconds`, `resetsAt` |
| Starts per rolling hour | $20 60, $100 210, $500 420, $2,000 600 | `429 rate_limited` | Same, with `window: "hour"` |
| Starts per rolling day | $20 200, $100 840, $500 1,680, $2,000 2,400 | `429 daily_limit_reached` | Same, with `window: "day"` |
| API requests | 600 a minute per API key (per user for dashboard and CLI sessions), bursts up to 300. Covers the API and hosted MCP, separate from the start limits | `429 rate_limited` | `Retry-After` header and `details.window: "requests"`, no `resetsAt` |
| Room on the fleet for a size | Changes minute to minute | `503 capacity_exceeded` | `details.available`, `details.availableBySize` |
| A ready machine right now | Only checked with `failFast: true` | `503 no_ready_machine` | `details.retryable: true` |

A few details from the source that change how you schedule:

- **Creates, resumes and forks are starts.** A `failFast` request refused with `no_ready_machine` is not counted.
- **The start windows roll.** `resetsAt` is when enough old starts age out of the window, not midnight. With several windows full, it's the latest of them, and the code becomes `daily_limit_reached` once the day window is full.
- **Order of checks.** The account checks (billing, size, concurrency, then start windows) run before the capacity check. A `failFast` create on a full fleet gets `capacity_exceeded`, not `no_ready_machine`. Resumes skip the capacity check because they go back to the node holding their data.
- **Active means these states:** `provisioning`, `provisioned`, `cloning`, `ready`, `idle`, `running`. A sandbox that is stopping, stopped or deleted doesn't count.

## Wait, queue, fall back or give up

Turn each refusal into one of five actions. The helpers below use exactly this mapping.

| Code | Action | Who frees it | How long to wait |
| - | - | - | - |
| `no_ready_machine`, `capacity_exceeded` | Try the next size now. After the last size, back off and start over | The fleet | Jitter, 0.5 s doubling to 30 s |
| `rate_limited`, `daily_limit_reached` | Wait, unless the wait passes your deadline | The clock | `Retry-After` (same as `details.retryAfterSeconds`) |
| `limit_reached`, `member_limit_reached` | Queue. Don't count it as a failure | You, by stopping or deleting a sandbox | Until one of yours ends; else slow jitter from 5 s |
| `idempotency_in_progress`, `card_check_pending`, any `5xx`, network errors | Retry the same request with the same key | The server | Jitter, 0.5 s doubling to 30 s |
| Every other `4xx` | Give up and report `code` and `requestId` | Nobody, the request must change | Don't retry |

The "would the wait pass my deadline" check is what turns a wait into a give-up. A `daily_limit_reached` with `Retry-After: 30000` (over 8 hours) should fail the task now and tell the user when starts free up. Holding a worker for hours helps nobody. The `sbx` CLI draws the same line: it waits out `rate_limited` when `retryAfterSeconds` is 60 or less, and returns at once otherwise.

## Size the semaphore from GET /limits

A semaphore stops you from finding the concurrency cap with a 429. Size it once at startup from `GET /limits`. This is our test account's real answer, trimmed to the fields that matter:

```bash
sbx limits --json | jq -c '{maxActiveSandboxes, activeSandboxes, activeStates, minute: .starts.minute.limit, startLimitsExempt: .currentLimits.startLimitsExempt, standardLimits}'
```

```json
{"maxActiveSandboxes":100,"activeSandboxes":6,"activeStates":["provisioning","provisioned","cloning","ready","idle","running"],"minute":1073741824,"startLimitsExempt":true,"standardLimits":{"activeSandboxes":100,"creationRatePerMinute":12,"creationRequestsPerDay":200,"creationRequestsPerHour":60}}
```

`startLimitsExempt: true` and the huge minute limit are the internal-account exemption. On a customer account on the $20/month plan, `starts.minute.limit` is 12. The rules we follow:

- **Slots = `maxActiveSandboxes - activeSandboxes`**, capped by whatever share you want this process to use. Other workers, teammates and forgotten sandboxes hold places too. That's why `limit_reached` stays a "queue" case even with a semaphore.
- **Hold the slot from create until the delete or stop is accepted.** A sandbox counts while it's provisioning, so acquire before `POST /sandboxes`. It stops counting once it leaves the active states, so release the slot after the `DELETE` is accepted, or once a stop has finished (`sbx stop --wait`).
- **Pace starts as well.** A semaphore of 100 still lets you fire 100 creates in one second, and the $20 plan allows 12 a minute. Keep the times of recent starts and sleep when the window is full.

## The Python helper

`gate.py` uses only the standard library. `Gate` reads `/limits` once, `Gate.start` applies the table above, and `Gate.sandbox` wraps a job in slot, create and delete:

```python
# gate.py: start Sandboxer sandboxes under your plan's limits (Python 3.9+, stdlib only)
import json, os, random, threading, time, urllib.error, urllib.request
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timezone

API = os.environ.get("SANDBOXER_API_URL", "https://sandboxer.com/api/v1")


class GiveUp(Exception):
    pass


def request(method, path, body=None, headers=None):
    h = {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}", **(headers or {})}
    data = None
    if body is not None:
        h["Content-Type"] = "application/json"
        data = json.dumps(body).encode()
    req = urllib.request.Request(API + path, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(req, timeout=90) as res:
            return res.status, res.headers, json.loads(res.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, e.headers, json.loads(e.read() or b"{}")


def classify(status, code):
    """What a start refusal means for the caller."""
    if status < 300:
        return "ok"
    if code in ("no_ready_machine", "capacity_exceeded"):
        return "next_size"   # nothing was created; try the next size now, back off after the last
    if code in ("rate_limited", "daily_limit_reached"):
        return "wait"        # a start window is full; it frees at Retry-After / resetsAt
    if code in ("limit_reached", "member_limit_reached"):
        return "queue"       # too many active; only a stop or delete frees a place
    if code in ("idempotency_in_progress", "card_check_pending") or status >= 500:
        return "retry"       # transient; same key, backoff
    return "fail"            # every other 4xx: the request has to change


def backoff(attempt, base=0.5, cap=30.0):
    return random.uniform(0, min(cap, base * 2 ** attempt))   # full jitter


class Gate:
    def __init__(self, max_slots=None, max_wait=900, window=60.0):
        _, _, lim = request("GET", "/limits")
        free = max(1, lim["maxActiveSandboxes"] - lim["activeSandboxes"])
        self.size = min(free, max_slots or free)
        self.slots = threading.BoundedSemaphore(self.size)
        self.per_window = min(lim["starts"]["minute"]["limit"], 10_000)
        self.window, self.max_wait = window, max_wait
        self.recent, self.lock = deque(), threading.Lock()

    def _pace(self):
        # Stay under the per-minute start limit instead of finding it with a 429.
        with self.lock:
            now = time.monotonic()
            while self.recent and now - self.recent[0] > self.window:
                self.recent.popleft()
            if len(self.recent) >= self.per_window:
                time.sleep(self.window - (now - self.recent[0]))
                self.recent.popleft()
            self.recent.append(time.monotonic())

    def start(self, job_id, sizes=("default", "small"), **extra):
        deadline, attempt = time.monotonic() + self.max_wait, 0
        while True:
            for size in sizes:
                self._pace()
                body = {"type": size, "name": job_id, "failFast": True, **extra}
                # One key per job and size: a retry of the same body replays, a new size is a new request.
                status, headers, data = request("POST", "/sandboxes", body, {"Idempotency-Key": f"{job_id}-{size}"})
                kind = classify(status, data.get("code"))
                if kind == "ok":
                    return data["sandbox"]["id"], size
                if kind != "next_size":
                    break
            if kind == "fail":
                raise GiveUp(f"{status} {data.get('code')}: {data.get('message')} ({data.get('requestId')})")
            details = (data.get("error") or {}).get("details") or {}
            delay = float(headers.get("Retry-After") or details.get("retryAfterSeconds") or 0)
            delay = delay or backoff(attempt, base=5.0 if kind == "queue" else 0.5)
            if time.monotonic() + delay > deadline:
                raise GiveUp(f"{status} {data.get('code')}: would wait {delay:.0f}s, past max_wait ({data.get('requestId')})")
            print(f"{job_id}: {status} {data.get('code')} -> {kind}, sleeping {delay:.1f}s", flush=True)
            time.sleep(delay)
            attempt += 1

    @contextmanager
    def sandbox(self, job_id, **kw):
        with self.slots:                      # held from create until the delete is accepted
            sid, size = self.start(job_id, **kw)
            try:
                yield sid, size
            finally:
                request("DELETE", f"/sandboxes/{sid}", headers={"X-Sandboxer-Confirm-Delete": sid})


def ts():
    return datetime.now(timezone.utc).strftime("%H:%M:%S")
```

Three choices in it are easy to get wrong:

- **The idempotency key includes the size.** Falling back from `default` to `small` changes the body. Reusing the key would get `409 idempotency_key_reused`. With `job-42-default` and `job-42-small`, a retry of either size replays safely.
- **Every size is sent with `failFast: true`.** Without it, a create waits for capacity, and you lose the chance to fall back. A refused fail-fast create bills nothing and isn't counted as a start.
- **The pacer counts attempts, not successes.** That's conservative: a refused attempt may not count against your limit, but you don't need to know which ones do.

### Tested against a mock

We ran seven jobs at once against a local mock that answers like the API: the account allows 5 active sandboxes, 3 are already taken, and 3 starts are allowed per window. We shortened the window to 2 seconds so the test runs fast. Each job name triggers one scenario: `no-default` gets `503 no_ready_machine` for `default`, `rate` gets one `429 rate_limited` with `Retry-After: 2`, `full` gets `503 capacity_exceeded` for both sizes once, `daily` gets `429 daily_limit_reached` with `Retry-After: 30000`, and `badsize` gets `400 invalid_type`.

```python
import json, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
from gate import Gate, GiveUp, request, ts

gate = Gate(max_wait=60, window=2.0)   # mock minute limit is 3; a 2 s window keeps the test short
print("slots", gate.size, "starts per window", gate.per_window)

def job(name):
    try:
        with gate.sandbox(name) as (sid, size):
            status, _, out = request("POST", f"/sandboxes/{sid}/commands", {"command": "echo ok", "timeoutSeconds": 60})
            return f"{ts()} {name}: {sid} {size} exit={out['exitCode']}"
    except GiveUp as e:
        return f"{ts()} {name}: gave up: {e}"

jobs = ["job1", "job2-no-default", "job3-rate", "job4-full", "job5-daily", "job6-badsize", "job7"]
t0 = time.time()
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
    for line in pool.map(job, jobs):
        print(line)
stats = json.load(urllib.request.urlopen("http://127.0.0.1:8765/_stats"))   # the mock's own counters
print("max sandboxes at once:", stats["maxConcurrent"])
print("creates:", [(round(t - t0, 1), j, s) for t, j, s in stats["creates"]])
```

```text
slots 2 starts per window 3
10:42:26 job1: sb_mock1 default exit=0
10:42:26 job2-no-default: sb_mock2 small exit=0
job3-rate: 429 rate_limited -> wait, sleeping 2.0s
job4-full: 503 capacity_exceeded -> next_size, sleeping 0.3s
10:42:30 job3-rate: sb_mock4 default exit=0
10:42:30 job4-full: sb_mock3 default exit=0
10:42:30 job5-daily: gave up: 429 daily_limit_reached: would wait 30000s, past max_wait (req_mock)
10:42:32 job6-badsize: gave up: 400 invalid_type: type must be one of small, default, large, xlarge. (req_mock)
10:42:32 job7: sb_mock5 default exit=0
max sandboxes at once: 2
creates: [(-0.0, 'job1', 'default'), (-0.0, 'job2-no-default', 'small'), (4.0, 'job4-full', 'default'), (4.0, 'job3-rate', 'default'), (6.0, 'job7', 'default')]
```

The mock counted at most 2 sandboxes at once, matching the 2 free places, so it never had to send `limit_reached`. `job2` fell back to `small` with no wait. `job3` waited exactly the `Retry-After`. `job5` gave up at once instead of sleeping 8 hours. `job6` didn't retry.

### Once on production

Then the same helper on production, with the semaphore capped at one so the two jobs had to take turns:

```python
from concurrent.futures import ThreadPoolExecutor
from gate import Gate, GiveUp, request, ts

gate = Gate(max_slots=1)            # at most one of these sandboxes at a time
print(ts(), "slots", gate.size, "starts per minute", gate.per_window)

def job(name):
    try:
        with gate.sandbox(name, ttlSeconds=900) as (sid, size):
            print(ts(), name, "started", sid, size, flush=True)
            _, _, out = request("POST", f"/sandboxes/{sid}/commands", {"command": "nproc; uptime -p", "timeoutSeconds": 60})
            print(ts(), name, "exit", out.get("exitCode"), repr(out.get("stdout")), flush=True)
        print(ts(), name, "deleted", sid, flush=True)
        return sid
    except GiveUp as e:
        print(ts(), name, "gave up:", e)

with ThreadPoolExecutor(max_workers=2) as pool:
    ids = list(pool.map(job, ["batch-1", "batch-2"]))
for sid in ids:
    status, _, data = request("GET", f"/sandboxes/{sid}")
    print(sid, status, data.get("code") or data.get("sandbox", {}).get("state"))
```

```text
10:44:35 slots 1 starts per minute 10000
10:44:35 batch-1 started sb_u67mrj2lid default
10:44:36 batch-1 exit 0 '4\nup 0 minutes\n'
10:44:36 batch-1 deleted sb_u67mrj2lid
10:44:36 batch-2 started sb_uv2c5d66ze default
10:44:38 batch-2 exit 0 '4\nup 0 minutes\n'
10:44:38 batch-2 deleted sb_uv2c5d66ze
sb_u67mrj2lid 404 not_found
sb_uv2c5d66ze 404 not_found
```

The second job started only after the first sandbox's delete was accepted. A command sent right after the `202` from create ran without any polling, because the commands endpoint waits up to 60 seconds for a starting sandbox. Both sandboxes returned `404` afterwards. The "starts per minute 10000" is the helper's cap on the internal account's exempt value.

## The bash helper

`limits.sh` does the same with `curl` and `jq`. Concurrency comes from `xargs -P`, sized by `free_slots`. It doesn't pace starts. It relies on `Retry-After` instead, so on a plan with 12 starts a minute, a burst of 30 will see a few `rate_limited` waits.

```bash
# limits.sh: source it. Needs curl and jq. Reads SANDBOXER_API_KEY (and SANDBOXER_API_URL for tests).
API=${SANDBOXER_API_URL:-https://sandboxer.com/api/v1}
MAX_WAIT=${MAX_WAIT:-900}

# start_sandbox  ...  -> prints the sandbox id, or returns 1 with the reason on stderr
start_sandbox() {
  local job=$1; shift
  local tmp; tmp=$(mktemp -d)
  local attempt=0 deadline=$(( $(date +%s) + MAX_WAIT )) status code kind size delay
  while :; do
    for size in "$@"; do
      status=$(curl -sS -o "$tmp/body" -D "$tmp/hdr" -w '%{http_code}' -X POST "$API/sandboxes" \
        -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
        -H "Idempotency-Key: $job-$size" \
        -d "{\"type\":\"$size\",\"name\":\"$job\",\"failFast\":true}") || status=000
      code=$(jq -r '.code // empty' "$tmp/body" 2>/dev/null)
      case "$status:$code" in
        2??:*) jq -r '.sandbox.id' "$tmp/body"; rm -rf "$tmp"; return 0 ;;
        503:no_ready_machine|503:capacity_exceeded) kind=next_size; continue ;;
        429:rate_limited|429:daily_limit_reached) kind=wait ;;
        429:limit_reached|429:member_limit_reached) kind=queue ;;
        409:idempotency_in_progress|409:card_check_pending|5??:*|000:*) kind=retry ;;
        *) kind=fail ;;
      esac
      break
    done
    if [ "$kind" = fail ]; then
      echo "$job: give up: $status $code $(jq -r '.message' "$tmp/body" 2>/dev/null)" >&2; rm -rf "$tmp"; return 1
    fi
    delay=$(awk 'tolower($1)=="retry-after:" {print $2+0}' "$tmp/hdr" 2>/dev/null)
    if [ -z "$delay" ] || [ "$delay" = 0 ]; then              # full jitter: random 0..min(30, base*2^attempt)
      local base=0.5; [ "$kind" = queue ] && base=5
      delay=$(awk -v b=$base -v a=$attempt 'BEGIN{srand(); c=b*2^a; if(c>30)c=30; printf "%.1f", rand()*c}')
    fi
    if [ $(( $(date +%s) + ${delay%.*} )) -gt "$deadline" ]; then
      echo "$job: give up: $status $code, would wait ${delay}s" >&2; rm -rf "$tmp"; return 1
    fi
    echo "$job: $status $code -> $kind, sleeping ${delay}s" >&2
    sleep "$delay"; attempt=$((attempt + 1))
  done
}

# delete_sandbox 
delete_sandbox() {
  curl -sS -o /dev/null -w '%{http_code}' -X DELETE "$API/sandboxes/$1" \
    -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "X-Sandboxer-Confirm-Delete: $1"
}

# free_slots [cap]: how many more sandboxes the account may run now, capped
free_slots() {
  curl -sS "$API/limits" -H "Authorization: Bearer $SANDBOXER_API_KEY" |
    jq --argjson cap "${1:-1000000}" '[.maxActiveSandboxes - .activeSandboxes, $cap] | min | if . < 1 then 1 else . end'
}
```

The job runner runs one sandbox per job, with no more at once than the account has room for:

```bash
#!/usr/bin/env bash
# run_jobs.sh ...: one sandbox per job, never more at once than the account has room for
source "$(dirname "$0")/limits.sh"

run_job() {
  local job=$1 id
  id=$(start_sandbox "$job" default small) || return 1
  curl -sS -X POST "$API/sandboxes/$id/commands" -H "Authorization: Bearer $SANDBOXER_API_KEY" \
    -H "Content-Type: application/json" -d '{"command":"nproc; uptime -p","timeoutSeconds":60}' |
    jq -c --arg job "$job" --arg id "$id" '{job:$job, id:$id, exitCode, stdout}'
  echo "$job: deleted $id ($(delete_sandbox "$id"))" >&2
}
export -f run_job start_sandbox delete_sandbox; export API MAX_WAIT

slots=$(free_slots "${MAX_SLOTS:-}")
echo "running $# jobs, $slots at a time" >&2
printf '%s\n' "$@" | xargs -P "$slots" -I{} bash -c 'run_job "$1"' _ {}
```

The same seven scenarios against the mock:

```bash
SANDBOXER_API_URL=http://127.0.0.1:8766 MAX_WAIT=60 ./run_jobs.sh job1 job2-no-default job3-rate job4-full job5-daily job6-badsize job7
```

```text
running 7 jobs, 2 at a time
{"job":"job1","id":"sb_mock1","exitCode":0,"stdout":"ok\n"}
job1: deleted sb_mock1 (202)
{"job":"job2-no-default","id":"sb_mock2","exitCode":0,"stdout":"ok\n"}
job2-no-default: deleted sb_mock2 (202)
job3-rate: 429 rate_limited -> wait, sleeping 2s
job4-full: 503 capacity_exceeded -> next_size, sleeping 0.1s
{"job":"job4-full","id":"sb_mock3","exitCode":0,"stdout":"ok\n"}
job4-full: deleted sb_mock3 (202)
job5-daily: give up: 429 daily_limit_reached, would wait 30000s
job6-badsize: give up: 400 invalid_type type must be one of small, default, large, xlarge.
{"job":"job7","id":"sb_mock4","exitCode":0,"stdout":"ok\n"}
job7: deleted sb_mock4 (202)
{"job":"job3-rate","id":"sb_mock5","exitCode":0,"stdout":"ok\n"}
job3-rate: deleted sb_mock5 (202)
```

The mock again saw at most 2 sandboxes at once. `xargs` exited with 123 because two jobs gave up, which is what you want a CI step to see. Then one job on production:

```bash
MAX_SLOTS=1 ./run_jobs.sh batch-3
```

```text
running 1 jobs, 1 at a time
{"job":"batch-3","id":"sb_agbg2giamh","exitCode":0,"stdout":"4\nup 0 minutes\n"}
batch-3: deleted sb_agbg2giamh (202)
```

`sbx info sb_agbg2giamh` then returned `not_found` with exit code 4.

## If you use the SDKs or the CLI

The SDKs retry on their own, three times by default. Inside your own loop that multiplies the waits, so turn it off on the client you use for scheduled creates:

```python
from sandboxer import Sandboxer, SandboxerError

client = Sandboxer(max_retries=0)   # your own loop decides; the SDK doesn't retry underneath it
try:
    client.create(type="tiny", fail_fast=True, idempotency_key="job-42-tiny")
except SandboxerError as e:
    print(e.status, e.code, e.retryable, e.request_id)
limits = client.api.limits()
print(limits.max_active_sandboxes - limits.active_sandboxes, "free places")
```

```text
400 invalid_type False req_nBWMsslDnWN8yqZEbo2k
96 free places
```

What each client does by itself, from the source:

| Client | `429` with `Retry-After` | `503` on create (`no_ready_machine`, `capacity_exceeded`) | `limit_reached` |
| - | - | - | - |
| Python SDK `sandboxer-sdk` | Currently retries before raising, sleeping at most 60 s each time, even for a `daily_limit_reached` hours away. Catch `RateLimitError` and check `.retry_after` | Retries up to 3 times, which delays your size fallback | Raises `RateLimitError` |
| TypeScript SDK `sandboxer-sdk` | Retries only when `Retry-After` is 60 s or less | Retries up to 3 times | Throws |
| `sbx` CLI | Retries `rate_limited` when the wait is 60 s or less; returns `daily_limit_reached` at once | Returns at once (`sbx new --fail-fast` for `no_ready_machine`) | Returns at once |

With `max_retries=0` (Python) or `maxRetries: 0` (TypeScript), branch on `e.code` with the same table as the helpers.

## Don't poll your way into the request limit

The 600-a-minute request limit is per API key (per user for dashboard and CLI sessions), hosted MCP calls included, and every call counts, including `GET /sandboxes/{id}` in a wait loop. A hundred workers each polling once a second is 6,000 requests a minute: they drain the 300-request burst in about three seconds, then start getting `rate_limited` with `details.window: "requests"`. Ways to stay under it:

- Don't poll for `ready` before running a command. As the production run shows, a command sent right after create waits for the sandbox.
- If you must poll, poll every few seconds, and poll the list (`GET /sandboxes`) once instead of each sandbox.
- Give separate services separate API keys. Each key has its own bucket.

## FAQ

### Should I retry limit_reached?

Not on a timer alone. It means too many of your sandboxes are active, and only a stop or delete frees a place. Wait for one of your own jobs to finish, or back off slowly (we start at 5 seconds) if the places are held by another process.

### What should I do with daily_limit_reached?

Compare `Retry-After` (or `details.resetsAt`) with how long the task may wait. If it's past that, fail the task now and show the user `resetsAt`. The day window rolls, so starts free up as old ones age out, not at midnight.

### Why send failFast on every create?

Without it, a create waits for a machine instead of answering, so you can't fall back to another size. A fail-fast create refused with `no_ready_machine` creates nothing, bills nothing and isn't counted as a start.

### Why did my fallback get idempotency_key_reused?

You reused the key from the first size with a different `type` in the body. Make the key from the job and the size, like `job-42-default` and `job-42-small`.

### Can I see my plan's limits before a burst?

Yes. `GET /limits` (or `sbx limits --json`) returns `maxActiveSandboxes`, `activeSandboxes` and, under `starts`, the `limit`, `used`, `remaining` and `resetsAt` of each window.

## Related

- [Sandboxer API error codes](/guides/sandboxer-api-error-codes)
- [Retry sandbox API calls safely with idempotency keys](/guides/retry-sandbox-api-calls-safely-with-idempotency-keys)
- [Limits and errors](/docs/limits)
- [Run several coding agents in parallel](/guides/run-coding-agents-in-parallel)

---

# Should Each MCP Server Run in Its Own Sandbox? How to Set It Up

Source: https://sandboxer.com/guides/run-each-mcp-server-in-its-own-sandbox. Updated 2026-10-27.

**Short answer:** Yes, for any server you didn't write or that holds a secret. Run each one in its own --no-env sandbox with only its own -e variables, bridge stdio servers to Streamable HTTP on 127.0.0.1, host each port privately, and connect your client to every URL with its own __sbx_auth cookie header.

An agent with five MCP servers on your laptop runs five programs as you. Each one can read your home directory, your shell's environment and every other server's config file, and each one can reach your local network. Most servers need none of that. This page sets up the opposite: one microVM per server, so a server only has what you gave it.

If you want to try a server before you trust it, test it first with the [MCP Inspector in a sandbox](/guides/test-an-mcp-server-with-the-mcp-inspector). If you want an agent to *create* sandboxes through MCP, that's [Sandboxer's own MCP server](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex), a different thing.

## What one sandbox per server buys you

| Shared by servers on one machine | One sandbox per server |
| - | - |
| Filesystem: any server can read the others' files and config | Each VM has its own disk. Server B's sandbox had no `/home/user/project/notes.txt`, which server A serves |
| Secrets: every variable in the environment, every token file on disk | Each sandbox holds only its own `-e` values. Server B saw `EVERYTHING_SERVER_TOKEN`, never `FILES_SERVER_TOKEN` |
| Network: servers can talk to each other on localhost | Sandbox to sandbox is blocked. From B, A's SSH port and port 8000 both timed out |
| One access path for all of them | Each hosted URL has its own token. A's URL answered `401` to B's cookie |
| A crash, a runaway process or a memory leak hits all of them | Each server has its own 2 vCPU and 4 GB (`small`) |

The env row matters more than it looks. The reference [everything](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) server has a `get-env` tool that returns its whole process environment to the client. Plenty of real servers can read files or run shell commands, which amounts to the same thing. Whatever a server can read, the model can be talked into returning.

When it isn't worth it: a server you wrote that only calls one API with a key you'd give the agent anyway. A sandbox per server costs a hosted URL to manage and one network round trip per call (about 130 ms in our test, below).

## What we ran

On production, October 27, 2026, from a laptop in Miami. Two `small` sandboxes, each made with `--no-env`:

- **Sandbox A**: the reference [filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) server 2.0.1. It speaks stdio only, so [supergateway](https://github.com/supercorp-ai/supergateway) 4.2.1 serves it as Streamable HTTP.
- **Sandbox B**: the reference everything server 2.0.1, behind supergateway too, so both bind `127.0.0.1` the same way.
- **The client**: the MCP [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) 1.32.2 on the laptop, and Claude Code 2.1.311.

## 1. One sandbox per server, each with only its own secret

```bash
sbx new --name mcp-files --type small --no-env -e FILES_SERVER_TOKEN=not-a-real-secret-files
sbx new --name mcp-everything --type small --no-env -e EVERYTHING_SERVER_TOKEN=not-a-real-secret-everything
```

`--no-env` means the sandbox gets none of your account's variables, secret files, GitHub token or agent logins ([environments](/docs/environments)). The `-e` value is the one secret that server needs. Use the variable name your server's README asks for. Ours are fake.

A `--no-env` sandbox still gets a `SANDBOXER_TOKEN`. It can read only that one sandbox and host its ports. It can't run commands, create sandboxes or read your account ([pass secrets safely](/guides/pass-secrets-to-a-sandbox-safely) shows what it can and can't do). So a server that leaks its environment leaks its own secret and a token that can't touch anything else.

Sandboxes stop after an hour by default. For a server you want up all day, add `--ttl 28800` (8 hours) or more.

## 2. Run the server as a service on 127.0.0.1

This is sandbox A's setup. We piped it in with `sbx exec -i  --timeout 600 "bash -s" < setup-files.sh`:

```bash
set -e
sudo npm i -g --silent supergateway@4.2.1 @modelcontextprotocol/server-filesystem@2.0.1
mkdir -p /home/user/project && echo "only the files server can read this" > /home/user/project/notes.txt
sudo tee /etc/systemd/system/mcp-files.service >/dev/null <<'UNIT'
[Unit]
Description=MCP filesystem server over Streamable HTTP
After=network-online.target

[Service]
User=user
EnvironmentFile=/etc/sandboxer/env
ExecStart=/usr/bin/supergateway --stdio "mcp-server-filesystem /home/user/project" --outputTransport streamableHttp --host 127.0.0.1 --port 8000 --stateful --sessionTimeout 600000 --logLevel none
Restart=always

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload && sudo systemctl enable --now mcp-files
sleep 3
sudo ss -ltnp | grep ':8000 '
```

```text
Created symlink /etc/systemd/system/multi-user.target.wants/mcp-files.service → /etc/systemd/system/mcp-files.service.
LISTEN 0      511        127.0.0.1:8000      0.0.0.0:*    users:(("node",pid=636,fd=21))
```

It took 10 seconds. Sandbox B is the same script with `@modelcontextprotocol/server-everything@2.0.1` and `--stdio "mcp-server-everything stdio"`. Three lines in that unit are easy to miss:

- **`EnvironmentFile=/etc/sandboxer/env`.** systemd starts services with a clean environment, so without this line the server never sees its `-e` variable.
- **`--host 127.0.0.1`.** Each sandbox has a public IPv6 address. Its firewall is closed except SSH ([security](/docs/security#network)), but binding to loopback means the server isn't listening there at all.
- **`--stateful`.** Without it, supergateway starts a fresh copy of the stdio server for every request. Its log showed `Child exited` after each call, and calls took 270–410 ms. With it, one server process serves the whole session, and calls took 131 ms, about one round trip from Miami.

We also set `--logLevel none`. At the default level, supergateway writes every request and response body to the journal, including file contents. Leave it on while you debug (`journalctl -u mcp-files`), then turn it off.

## 3. Give each server a private URL

```bash
sbx host  8000 --localhost --title "MCP files"
sbx host  8000 --localhost --title "MCP everything"
```

```text
https://quick-clear-canyon-8000.box.sandboxer.com?_token=
https://clean-chief-hare-8000.box.sandboxer.com?_token=
```

`--localhost` makes the sandbox relay the hosted port to `127.0.0.1:8000` ([hosting](/docs/hosting#apps-that-listen-on-localhost)). Hosted URLs are private by default. A browser trades `_token` for a cookie, but an MCP client sends no cookies of its own. So take the token out of the URL and send it on every request as `Cookie: __sbx_auth=`. Sandboxer's proxy strips it before the request reaches the server.

Keep each token in its own environment variable on your laptop. We used `FILES_MCP_AUTH` and `EVERYTHING_MCP_AUTH`. The proxy checks each URL against its own token:

```bash
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
A=https://quick-clear-canyon-8000.box.sandboxer.com/mcp
curl -s -o /dev/null -w "no token: %{http_code}\n" -X POST $A "${H[@]}" -d "$INIT"
curl -s -o /dev/null -w "B's cookie: %{http_code}\n" -X POST $A "${H[@]}" -H "Cookie: __sbx_auth=$EVERYTHING_MCP_AUTH" -d "$INIT"
curl -s -w "\nown cookie: %{http_code}\n" -X POST $A "${H[@]}" -H "Cookie: __sbx_auth=$FILES_MCP_AUTH" -d "$INIT"
```

```text
no token: 401
B's cookie: 401
event: message
data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"secure-filesystem-server","version":"2.0.1"}},"jsonrpc":"2.0","id":1}

own cookie: 200
```

## 4. Connect one client to both

With the TypeScript SDK, pass the header in `requestInit`:

```javascript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// One entry per sandbox. Each hosted URL has its own token, read from the environment.
const servers = {
  files: { url: "https://quick-clear-canyon-8000.box.sandboxer.com/mcp", token: process.env.FILES_MCP_AUTH },
  everything: { url: "https://clean-chief-hare-8000.box.sandboxer.com/mcp", token: process.env.EVERYTHING_MCP_AUTH },
};

async function connect(name, { url, token }) {
  const transport = new StreamableHTTPClientTransport(new URL(url), {
    requestInit: { headers: { Cookie: `__sbx_auth=${token}` } },
  });
  const client = new Client({ name: "laptop", version: "1.0.0" });
  await client.connect(transport);
  return client;
}

const files = await connect("files", servers.files);
const everything = await connect("everything", servers.everything);
const text = (r) => r.content.map((c) => c.text).join("\n");

console.log((await files.listTools()).tools.length, (await everything.listTools()).tools.length);
console.log(text(await files.callTool({ name: "read_text_file", arguments: { path: "/home/user/project/notes.txt" } })));
console.log(text(await everything.callTool({ name: "echo", arguments: { message: "hi" } })));

// Which secrets can the everything server see? Print names only, never values.
const env = JSON.parse(text(await everything.callTool({ name: "get-env", arguments: {} })));
console.log(Object.keys(env).filter((k) => /TOKEN|SECRET|KEY/.test(k)).join(" "));
```

Our run, with timings added:

```text
files: connected in 557 ms secure-filesystem-server 2.0.1
everything: connected in 615 ms mcp-servers/everything 2.0.1
files tools (399 ms): 14
everything tools (323 ms): 16
files read_text_file (275 ms): only the files server can read this
everything echo (305 ms): Echo: hi
everything get-env, names only (305 ms): SANDBOXER_TOKEN EVERYTHING_SERVER_TOKEN
```

That was before `--stateful`. With it, five calls in a row took `208 131 131 130 131` ms on A and `298 131 131 132 131` ms on B. The ping from the laptop to the node was 123 ms, so nearly all of that is distance.

The last line is the point of the whole setup. The everything server can dump its environment, and its environment holds its own fake token and a sandbox token that can't act. `FILES_SERVER_TOKEN` isn't there, because it's in a different VM.

For Claude Code, write `${VAR}` into the header and let Claude Code fill it in from your environment. The single quotes keep the shell from expanding it, so the token never lands in `~/.claude.json`:

```bash
claude mcp add-json --scope user files '{"type":"http","url":"https://quick-clear-canyon-8000.box.sandboxer.com/mcp","headers":{"Cookie":"__sbx_auth=${FILES_MCP_AUTH}"}}'
claude mcp add-json --scope user everything '{"type":"http","url":"https://clean-chief-hare-8000.box.sandboxer.com/mcp","headers":{"Cookie":"__sbx_auth=${EVERYTHING_MCP_AUTH}"}}'
claude mcp list
```

```text
files: https://quick-clear-canyon-8000.box.sandboxer.com/mcp (HTTP) - ✔ Connected
everything: https://clean-chief-hare-8000.box.sandboxer.com/mcp (HTTP) - ✔ Connected
```

The config file held `"Cookie": "__sbx_auth=${FILES_MCP_AUTH}"`, not the token. With a wrong value in `FILES_MCP_AUTH`, the check said `✘ Failed to connect — Dynamic Client Registration rejected (HTTP 401)`. Claude Code reads a `401` as a cue to try OAuth, which a hosted port doesn't offer. If you see that message, check the variable. Other clients that let you set headers on an HTTP server work the same way; see [local or hosted MCP](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex) for where Codex and Cursor keep theirs.

## What it costs

Each `small` sandbox is $0.018/hour while it runs and bills no running time while stopped or paused ([pricing](/pricing)). Two servers:

| How you run them | Running time billed | Cost |
| - | - | - |
| An 8-hour workday | 2 × 8 h | $0.29 a day |
| Around the clock, 30 days | 2 × 720 h | $25.92 a month |
| With idle auto-pause | Only while they're in use | Less, by how long they sit idle |

Idle auto-pause is on by default. A sandbox with no activity pauses with its memory kept. The next request with the URL's token wakes it, and the proxy holds that request while it resumes, up to 45 seconds ([wake on use](/docs/long-running#idle-auto-pause-and-wake-on-use)). For an MCP server that's a slow first call, not an error. We set sandbox B to pause after 1 minute (`sbx idle  1`). It paused, and a new client connected through the paused URL in 1,188 ms. The next call took 293 ms. Afterwards the sandbox reported `memoryRestored: true`, and the server still had the PID it started with. `sbx idle  60` sets the delay in minutes. Paused and stopped sandboxes still count toward your plan's included storage.

## FAQ

### Can I run several MCP servers in one sandbox instead?

Yes. Group servers that you trust equally and that need the same secrets, like two servers for one internal API. You save a sandbox, but each of them can read the other's files and environment.

### Why not just give the server the API key through the client config?

For a stdio server on your laptop, the client passes that key in the server's environment. The server process still runs as you and can read every other server's config. A sandbox limits what it can reach, not just what it's handed.

### Do I need supergateway if my server already speaks Streamable HTTP?

No. Run the server's own HTTP mode and host its port. If it can't bind to `127.0.0.1`, host it without `--localhost`. Only hosted ports are reachable through the proxy, and the sandbox's IPv6 firewall stays closed except SSH.

### Can the agent reach a server's sandbox some other way?

Only through the hosted URL you gave it. Sandboxes can't reach each other, and each URL needs its own token. `sbx host hide  8000` takes a URL down. Hosting the port again gives the old token back, and `--private` mints a new one.

### What happens to an open MCP session when the sandbox pauses?

It carries on. We opened a session to sandbox B, left it until the sandbox paused, then called `echo` on the same session. The call took 916 ms instead of 131 ms and returned `Echo: same session after pause`. Memory was restored, so the server process and its session were still there.

## Related

- [Test an MCP server with the MCP Inspector](/guides/test-an-mcp-server-with-the-mcp-inspector)
- [Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely)
- [Local or hosted MCP for Cursor, Claude Code and Codex](/guides/local-or-hosted-mcp-for-cursor-claude-code-codex)
- [Hosting docs](/docs/hosting)

---

# How Do I Shard a Test Suite Across Several Sandboxes?

Source: https://sandboxer.com/guides/shard-a-test-suite-across-sandboxes. Updated 2026-10-27.

**Short answer:** Install the project once, save it with sbx snapshot, then start one sandbox per shard with sbx new --from and run pytest-split's --splits N --group i in each. Copy each JUnit file back, merge them and delete the sandboxes. On prod, two small shards cut the networkx 3.5 suite from 96.9 s to 59.3 s.

## Results

We ran the full test suite of [networkx](https://github.com/networkx/networkx) at the `networkx-3.5` release (commit `4fa222d2fb157e1b7f8c753c9f92e5907d1ddeb4`) on prod on 2026-10-27, on `small` sandboxes (2 vCPU, 4 GB) in eu-central. One run per row.

| Setup | Wall time | Sandbox time billed | Cost at $0.018/hour |
| - | - | - | - |
| 1 sandbox, plain `pytest` | 96.9 s | about 103 s (3.2 s create + 96.9 s + teardown) | about $0.0005 |
| 1 sandbox, `pytest -n 2` (pytest-xdist) | 58.9 s | about 65 s | about $0.0003 |
| **2 sandboxes, one shard each** | **59.3 s**, create to last delete | 103.8 s (44.5 s + 59.3 s) | **$0.0005** |

What the numbers say:

- **Two shards cost about the same as one, and finish 39% sooner.** Sharding adds sandboxes, not test work. The extra cost is each shard's fixed overhead: create, Python start-up, collection, copying results back, delete.
- **Use the vCPUs you already have first.** A `small` sandbox has 2 vCPU, and plain pytest uses one. `pytest -n 2` in a single sandbox matched two shards on wall time with about 40% less sandbox time (65 s against 103.8 s). Sharding across sandboxes pays off once each sandbox's cores are busy. We didn't measure the two combined (two shards, each with `-n 2`).
- **The shards were not balanced.** Shard 1 ran 48.6 s of pytest, shard 2 ran 33.8 s, and the run waits for the slower one. Why, and the fix, is [below](#balance-the-shards).

The "1 sandbox" rows ran in the prepared sandbox before we saved the snapshot. Their billed time adds the 3.2 s create we measured for the shards; we didn't time a separate delete for them.

## The pattern

| Step | Command | How often |
| - | - | - |
| Clone at a pinned commit, install, record test durations | `sbx new`, `sbx exec` | Once per lockfile change |
| Save the prepared disk | `sbx snapshot  ` | Once |
| One sandbox per shard | `sbx new --from ` | N times, in parallel |
| Run shard i of N | `pytest --splits N --group i --junitxml=…` | Once per shard |
| Keep the JUnit file, not the VM | `sbx exec … cat`, then `sbx delete  --yes` | Once per shard |
| Merge | `python3 merge.py results/junit-*.xml` | Once |

Saving and reusing the prepared disk is covered in depth in [cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot). This page is about what you do with it: split the work, collect the results and decide how many shards are worth it. If you want N different attempts at a fix rather than N slices of one test run, see [best-of-N agents with parallel forks](/guides/best-of-n-agents-with-parallel-forks).

## 1. Prepare once and record durations

Create a sandbox, clone networkx at the pinned commit and install it with its test extras, plus [pytest-split](https://github.com/jerry-git/pytest-split) for sharding:

```bash
sbx new --name test-shard-1 --type small --ttl 5400
sbx exec sb_ujjwkl4zej --timeout 600 "set -e; python3 --version; nproc; \
  git clone -q https://github.com/networkx/networkx && cd networkx \
  && git checkout -q cd59914a6e46b04f006633f203ee7b4878d53159 && git log -1 --format='%h %s' \
  && python3 -m venv .venv && .venv/bin/pip install -q -e .[default,test] pytest-split==0.10.0 junitparser \
  && .venv/bin/pip list | grep -iE '^(pytest|numpy|scipy|pandas|matplotlib|pytest-split|junitparser) '"
```

```text
Python 3.12.3
2
4fa222d2f Designate 3.5 release
junitparser     5.0.3
matplotlib      3.11.2
numpy           2.5.4
pandas          3.1.0
pytest          8.4.2
pytest-split    0.10.0
scipy           1.18.1
```

`cd59914` is the annotated tag object for `networkx-3.5`; it checks out commit `4fa222d`. The install took 33 s. Pip resolved the newest numpy, scipy and pandas that day. For results you can repeat, install from a lockfile instead.

Then run the whole suite once with `--store-durations`. pytest-split writes each test's time to `.test_durations`, and it uses that file to balance the shards later:

```bash
sbx exec sb_ujjwkl4zej --detach "cd networkx && .venv/bin/pytest -q -p no:cacheprovider \
  --store-durations --junitxml=/home/user/junit-full.xml networkx > /home/user/full.log 2>&1"
```

```text
6532 passed, 67 skipped, 38 warnings in 96.85s (0:01:36)
```

For the in-sandbox comparison we also installed [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) before saving the snapshot:

```bash
sbx exec sb_ujjwkl4zej --timeout 120 "cd networkx && .venv/bin/pip install -q pytest-xdist==3.8.0"
```

The durations file had 6,596 entries summing to 84.2 s; the other 12 s or so is start-up and collection. Because `.test_durations` is on the disk, the snapshot carries it to every shard. When you add tests, rerun this step and save the snapshot again.

## 2. The shard script

`shard.sh` lives in the sandbox's home directory, so it's in the snapshot too. It runs one group, writes `~/junit-.xml` and prints one summary line:

```bash
#!/bin/bash
# shard.sh   [extra pytest args]: run one shard, write ~/junit-.xml
cd ~/networkx
group=$1 splits=$2; shift 2
start=$(date +%s%N)
.venv/bin/pytest -q -p no:cacheprovider --splits "$splits" --group "$group" \
  --junitxml="$HOME/junit-$group.xml" "$@" networkx > "$HOME/shard-$group.log" 2>&1
rc=$?
echo "group $group/$splits exit=$rc wall=$(( ($(date +%s%N) - start) / 1000000 ))ms | $(tail -1 ~/shard-$group.log)"
```

With `1 1` it runs the whole suite. That gave the two one-sandbox rows in the results table:

```bash
sbx exec sb_ujjwkl4zej --timeout 300 "./shard.sh 1 1"
sbx exec sb_ujjwkl4zej --timeout 300 "./shard.sh 1 1 -n 2"
```

```text
group 1/1 exit=0 wall=96855ms | 6532 passed, 67 skipped, 38 warnings in 90.13s (0:01:30)
group 1/1 exit=0 wall=58926ms | 6532 passed, 67 skipped, 44 warnings in 56.00s
```

Clean up the run's output, save the snapshot and delete the prepared sandbox. The snapshot outlives it:

```bash
sbx exec sb_ujjwkl4zej "rm -f junit-*.xml shard-*.log; ls"
sbx snapshot sb_ujjwkl4zej shard-nx-4fa222d
sbx delete sb_ujjwkl4zej --yes
```

```text
networkx
shard.sh
{"ok":true,"type":"snapshot.named.info","snapshot":{…,"name":"shard-nx-4fa222d",…,"status":"ready","type":"small",…}}
```

Saving took 2.1 s. Put the commit in the snapshot name, so a new commit gets a new snapshot instead of silently reusing the old one.

## 3. Run the shards in parallel

`run-shards.sh` runs on your laptop or CI runner and needs `sbx` and `jq`. It starts one background job per shard. Each job creates a sandbox from the snapshot, runs its group, copies the JUnit file back and deletes the sandbox:

```bash
#!/bin/bash
# run-shards.sh  : N sandboxes from one named snapshot, one shard each, in parallel.
snap=$1 N=$2
mkdir -p results && rm -f results/*
ms() { date +%s%3N; }
t0=$(ms)

run_shard() {
  local g=$1 id t
  t=$(ms)
  id=$(sbx new --from "$snap" --name "test-shard-$((g + 1))" --ttl 1800 \
       | jq -r 'select(.event == "ready") | .id')
  echo "shard $g/$N: $id ready in $(( $(ms) - t ))ms"
  sbx exec "$id" --timeout 600 "./shard.sh $g $N" | tee "results/$g.txt"
  sbx exec "$id" "gzip -c junit-$g.xml | base64 -w0" | base64 -d | gunzip > "results/junit-$g.xml"
  sbx delete "$id" --yes > /dev/null
  echo "shard $g/$N: deleted $id, $(( $(ms) - t ))ms from create to delete"
}

for g in $(seq 1 "$N"); do run_shard "$g" & done
wait
echo "all $N shards done in $(( $(ms) - t0 ))ms"
python3 merge.py results/junit-*.xml > junit.xml
```

Notes on the choices:

- **`--ttl 1800`.** If the script dies between create and delete, the sandbox stops on its own after 30 minutes instead of running on.
- **gzip and base64 for the JUnit file.** `sbx exec` returns the command's output, so the file comes back through it. Shard 1's XML was 520 KB; compressing it keeps the transfer small and binary-safe.
- **The sandbox goes as soon as its file is back.** With per-second billing, a finished shard that stays up waiting for the others costs money and does nothing.
- **For suites longer than 10 minutes,** synchronous `sbx exec` stops at `--timeout 600`. Start the shard with `sbx exec --detach` and poll `sbx exec  --status ` instead.

The output from prod:

```bash
./run-shards.sh shard-nx-4fa222d 2
```

```text
shard 1/2: sb_0puccgpvfo ready in 3187ms
shard 2/2: sb_afpu973qm7 ready in 3188ms
group 2/2 exit=0 wall=38658ms | 2798 passed, 48 skipped, 3753 deselected, 12 warnings in 33.80s
shard 2/2: deleted sb_afpu973qm7, 44519ms from create to delete
group 1/2 exit=0 wall=53311ms | 3734 passed, 22 skipped, 2843 deselected, 32 warnings in 48.63s
shard 1/2: deleted sb_0puccgpvfo, 59277ms from create to delete
all 2 shards done in 59282ms
merged 2 files: tests=6602, failures=0, errors=0, skipped=70, summed test time=82.4s
```

Both sandboxes were ready in 3.2 s. Passed tests add up exactly: 3,734 + 2,798 = 6,532, the same as the single run. The merged file shows 70 skips, not 67, because each shard collected all 6,599 tests and selected 3,756 + 2,846 = 6,602. The 3 extra are tests missing from the durations file (6,596 entries against 6,599 collected), which both shards ran; all 3 are skips. Count passes and failures, not totals, when you compare a sharded run with an unsharded one.

## 4. Merge the JUnit files

Most CI systems accept several JUnit files, so you may not need to merge. When you want one file, `merge.py` uses only the Python standard library:

```python
# merge.py: merge JUnit XML files from pytest into one  document on stdout.
import sys
import xml.etree.ElementTree as ET

out = ET.Element("testsuites")
total = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
time = 0.0
for path in sys.argv[1:]:
    root = ET.parse(path).getroot()
    for suite in root.iter("testsuite"):
        suite.set("name", f"{suite.get('name')}:{path}")
        out.append(suite)
        for k in total:
            total[k] += int(suite.get(k, 0))
        time += float(suite.get("time", 0))
for k, v in total.items():
    out.set(k, str(v))
out.set("time", f"{time:.2f}")
ET.ElementTree(out).write(sys.stdout.buffer, encoding="utf-8", xml_declaration=True)
print(f"merged {len(sys.argv) - 1} files: " + ", ".join(f"{k}={v}" for k, v in total.items())
      + f", summed test time={time:.1f}s", file=sys.stderr)
```

```xml

` times in each JUnit file gives 45.6 s for shard 1 and 31.1 s for shard 2. pytest-split's default algorithm, `duration_based_chunks`, cuts the test list into contiguous runs and keeps the original order. Its README says the other algorithm, `least_duration`, "should give more balanced groups":

```bash
pytest --splits 2 --group 1 --splitting-algorithm least_duration
```

We didn't rerun with it, so we have no number for the gain. Two other causes of imbalance are under your control: durations recorded on a different machine size than the shards, and tests added since the durations were recorded.

## How many shards: a model, not a measurement

We measured 1 and 2 shards only. What follows is arithmetic from the 2-shard run, not a result.

Each shard paid about 13.6 s that doesn't shrink with N: 3.2 s to create, about 7.6 s of pytest start-up and collection (shard wall time minus the sum of its test times), and about 2.8 s to copy the results back and delete. The test work was 76.7 s in total. With perfectly balanced shards:

- wall time ≈ 13.6 + 76.7 / N seconds
- billed sandbox time ≈ 13.6 × N + 76.7 seconds

For N = 2 the formula gives 52 s where we measured 59.3 s; the gap is the imbalance above. The formula says the test part shrinks with N while the fixed overhead is paid once per shard, so once 76.7 / N gets close to 13.6, more shards mostly add cost. We haven't measured where that point falls; run 4 or more shards on your own suite before you rely on it. A suite with more test time per shard, or a heavier setup that the snapshot already did, moves that point higher.

Two limits apply whatever the model says. Each `sbx new` counts as a start against your plan's per-minute, per-hour and per-day start limits, and the number of sandboxes running at once is capped per plan. See [limits](/docs/limits) and [billing](/docs/billing#plans).

## FAQ

### Why a named snapshot and not sbx fork?

A fork needs its source sandbox to still exist. A named snapshot doesn't, so you can delete the prepared sandbox and start shards from the snapshot on every CI run, from any machine with the API key.

### Can I shard without recording durations first?

Yes. Without a `.test_durations` file, pytest-split splits by test count, which can be far off when a few tests are slow. Recording durations once and saving them in the snapshot costs one full run.

### Does a failing shard stop the others?

No. Each shard is its own sandbox and its own background job. `shard.sh` prints the pytest exit code, the merged JUnit file counts every failure, and your CI step decides whether to fail.

### Should I use pytest-xdist inside each shard?

Yes, if your tests are safe to run in parallel processes. On a `small` sandbox, `pytest -n 2` alone matched two shards on wall time with about 40% less sandbox time. Use the cores in each sandbox first, then add sandboxes.

### What happens to a shard sandbox if my laptop disconnects?

It keeps running until its TTL ends, then stops; the script passes `--ttl 1800` for that reason. A stopped sandbox isn't billed for time, but its disk counts toward your plan's included storage, so delete leftovers with `sbx delete  --yes`.

## Related

- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Best-of-N agents with parallel forks](/guides/best-of-n-agents-with-parallel-forks)
- [Run each GitHub Actions job in a fresh microVM](/guides/run-each-github-actions-job-in-a-fresh-microvm)
- [Snapshots, forks and templates](/docs/snapshots)

---

# How Do I Run Scheduled Jobs in a Sandbox With systemd Timers?

Source: https://sandboxer.com/guides/run-scheduled-jobs-in-a-sandbox-with-systemd-timers. Updated 2026-10-27.

**Short answer:** Use a systemd timer with OnCalendar and Persistent=true, or cron. Neither fires while the sandbox is stopped or idle-paused. On a memory resume, missed wall-clock jobs run right away; elapsed-time timers don't count the stopped time. On a cold boot, only Persistent=true catches up. For exact schedules, keep it awake or schedule from outside.

A stopped sandbox has no running CPU and no running clock, so nothing inside it can fire. What matters is what each kind of schedule does when the sandbox comes back. We measured that on prod on October 27, 2026, with one `small` sandbox, three timers and a crontab, across two memory resumes and one cold boot.

## At a glance

| Schedule | While stopped or paused | After a memory resume | After a cold boot |
| - | - | - | - |
| `OnCalendar=` with `Persistent=true` | doesn't fire | runs at once, sometimes twice | runs once at boot |
| `OnCalendar=` without `Persistent=` | doesn't fire | not tested | missed runs are skipped |
| `OnUnitActiveSec=` (elapsed time) | doesn't fire | may not count the stopped time (about 60 s counted in our runs) | starts counting again from boot |
| cron `*/2 * * * *` | doesn't fire | runs at once, one to three times | missed runs are skipped |
| Your own scheduler outside, calling `sbx resume` + `sbx exec` | fires on time | n/a | n/a |

The rest of this page shows the runs behind each cell. For what pausing and waking are, see [idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use) and [pause and resume with memory](/guides/pause-and-resume-a-sandbox-with-memory).

## Set up a timer and a cron job

One template service writes a line per run, so every timer logs to the same file:

```ini
# /etc/systemd/system/tick@.service
[Unit]
Description=Log one tick for %i

[Service]
Type=oneshot
User=user
ExecStart=/bin/sh -c 'echo "%i $(date -u +%%H:%%M:%%S)" >> /home/user/ticks.log'
```

```ini
# /etc/systemd/system/tick-cal.timer: wall clock, every 2 minutes, with catch-up
[Timer]
OnCalendar=*:0/2
Persistent=true
Unit=tick@cal.service

[Install]
WantedBy=timers.target
```

```ini
# /etc/systemd/system/tick-mono.timer: 2 minutes after the last run
[Timer]
OnActiveSec=2min
OnUnitActiveSec=2min
Unit=tick@mono.service

[Install]
WantedBy=timers.target
```

We wrote the three files with `sudo tee` from a local script piped in with `sbx exec -i $id "bash -s" < setup.sh`, then enabled them:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now tick-cal.timer tick-mono.timer
```

cron isn't installed in the base image. Installing it enables the service, but the image's policy blocks package scripts from starting it, so start it yourself:

```bash
sbx exec $id --timeout 300 "sudo apt-get update -qq; sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cron"
sbx exec $id "sudo systemctl start cron"
echo '*/2 * * * * echo "cron $(date -u +\%H:\%M:\%S)" >> /home/user/ticks.log' | sbx exec -i $id "crontab -"
```

Three minutes later:

```text
cron 10:44:01
cal 10:44:03
cron 10:46:01
cal 10:46:03
mono 10:46:03
```

The elapsed-time timer was due at 10:45:38 and ran at 10:46:03. That's systemd's default `AccuracySec=` of one minute, which lets it batch wakeups; set `AccuracySec=1s` if you need the exact second. See [systemd.timer](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html).

To read a unit's log, use `journalctl -u tick@cal.service`. That works without sudo in sandboxes created after 11:17 UTC on October 27, 2026. Between 10:44 and 11:17 UTC that day it needs `sudo`; in older sandboxes, including the one on this page, use `sudo journalctl -m -u tick@cal.service`.

## While stopped: nothing runs

We stopped the sandbox with `sbx stop $id --wait` at 10:47:33 and resumed it at 10:55:14. Four calendar slots (10:48 to 10:54) and three cron slots passed while it was stopped. None of them ran at their time. The log has nothing between 10:46:03 and the resume.

An idle pause is the same stop, so this holds for a sandbox that paused itself too. A timer firing inside a paused sandbox can't wake it, because nothing inside is running. Only an API call, a hosted-URL request or `sbx ssh` wakes it.

## After a memory resume

```bash
sbx resume $id --json | tail -1 | jq -c '.sandbox | {state, memoryRestored, resumeReason}'
sbx exec $id "date -u +%T; cat ~/ticks.log; systemctl list-timers 'tick-*' --no-pager | head -3"
```

```text
{"state":"ready","memoryRestored":true,"resumeReason":null}
10:55:15
…
mono 10:46:03
cron 10:55:14
cal 10:55:14
mono 10:55:14
cal 10:55:14
NEXT                            LEFT LAST                        PASSED UNIT            ACTIVATES
Tue 2026-10-27 10:56:00 UTC      44s Tue 2026-10-27 10:55:14 UTC 1s ago tick-cal.timer  tick@cal.service
Tue 2026-10-27 10:57:14 UTC 1min 58s Tue 2026-10-27 10:48:30 UTC 1s ago tick-mono.timer tick@mono.service
```

Everything ran within a second of the resume, and the calendar job ran twice. The journal shows why:

```text
Oct 27 10:48:30.759019 lazy-solar-river systemd[1]: Starting tick@cal.service - Log one tick for cal...
Oct 27 10:55:14.529484 lazy-solar-river systemd[1]: Finished tick@cal.service - Log one tick for cal.
Oct 27 10:55:14.538921 lazy-solar-river systemd[1]: Starting tick@cal.service - Log one tick for cal...
```

In our runs the guest woke with its clock about a minute past the moment it stopped (10:48:30), not at the real time. The guest clock isn't NTP-synced, so it can start slightly behind or ahead before it's corrected. Jobs due in that first minute start. Milliseconds later the clock is corrected to 10:55:14, and every wall-clock schedule sees that it missed slots and runs again. The elapsed-time timer ran only because its deadline, 10:48:03, fell inside that first minute.

### Elapsed-time timers don't count the stopped time

We stopped again at 10:55:37, with the elapsed-time timer due at 10:57:14, and resumed at 11:00:42. Just before the stop, `cat /proc/uptime` read `319.51` at 10:55:35. Right after the resume it read `381.08` at 11:00:43, and the timers were:

```text
NEXT                            LEFT LAST                              PASSED UNIT            ACTIVATES
Tue 2026-10-27 11:01:20 UTC      37s Tue 2026-10-27 10:48:30 UTC 1min 22s ago tick-mono.timer tick@mono.service
Tue 2026-10-27 11:02:00 UTC 1min 16s Tue 2026-10-27 11:00:42 UTC    919ms ago tick-cal.timer  tick@cal.service
```

The wall clock moved 308 seconds. Uptime, the monotonic clock these timers use, moved 61.6. The first stop gave the same result: 464 seconds of wall time, 61.5 of uptime. Elapsed-time timers may not count all of the stopped time. So the 10:57:14 run was pushed out to 11:01:20, and it didn't run at the resume. A job set to run "every 6 hours" with `OnUnitActiveSec=6h` can drift by the whole time the sandbox spent stopped. Use `OnCalendar=` for anything tied to the time of day. [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume#timers-after-a-memory-resume) has the same effect measured with `sleep`.

### cron caught up too

After the second resume the log had three `cron 11:00:42` lines and two `cal 11:00:42` lines. cron ran the 10:56 slot at the stale clock, then the 10:58 and 11:00 slots after the clock jumped. After the first, longer stop it ran only once. The [cron man page](https://manpages.ubuntu.com/manpages/noble/man8/cron.8.html) describes this clock-change handling: after a forward change of less than 3 hours, jobs from the skipped time run soon after the change.

**What to do:** make every scheduled job safe to run twice in a row. Have it take a lock (`flock`), or check a "last run" timestamp it writes, and exit early when it ran less than one interval ago.

## After a cold boot

A resize always boots from disk, so we used it to force one. We stopped at 11:01:04 and resumed at 11:06:34 on the next size up:

```bash
sbx resume $id --type default --json | tail -1 | jq -c '.sandbox | {state, type, memoryRestored, resumeReason}'
sbx exec $id "cat /proc/uptime; tail -6 ~/ticks.log; systemctl is-active cron; systemctl list-timers 'tick-*' --no-pager | head -4"
```

```text
{"state":"ready","type":"default","memoryRestored":false,"resumeReason":"resized"}
1.23 2.74
cron 11:00:42
cron 11:00:42
cron 11:00:42
cal 11:00:42
cal 11:00:42
cal 11:06:35
active
NEXT                            LEFT LAST                           PASSED UNIT            ACTIVATES
Tue 2026-10-27 11:08:00 UTC 1min 23s Tue 2026-10-27 11:06:35 UTC 738ms ago tick-cal.timer  tick@cal.service
Tue 2026-10-27 11:08:00 UTC 1min 23s -                                   - tick-np.timer   tick@np.service
Tue 2026-10-27 11:08:35 UTC 1min 59s -                                   - tick-mono.timer tick@mono.service
```

`tick-np.timer` is a copy of the calendar timer without `Persistent=true`, added before this stop. Three slots (11:02, 11:04, 11:06) were missed:

- The `Persistent=true` timer ran once, a second after boot. systemd keeps its last run time in `/var/lib/systemd/timers/`, which is on the disk.
- The timer without `Persistent=` skipped them all and waits for 11:08.
- The elapsed-time timer started over: next run two minutes after boot.
- cron came back, because installing it enabled the service, but it didn't run the missed slots.

Timers and cron only come back after a cold boot because they're enabled units. See [which way of starting a service survives a cold boot](/guides/run-a-service-that-comes-back-after-resume).

## Keep it awake for exact schedules

If a job must run at its time, the sandbox has to be running at that time. Turn off both stops:

```bash
sbx idle $id 0                    # never pause for being idle
sbx extend $id --no-auto-stop     # no auto-stop deadline
sbx info $id --json | jq -c '.sandbox | {archiveAfter, idleStopMinutes}'
```

```json
{"archiveAfter":null,"idleStopMinutes":0}
```

A timer inside the sandbox doesn't count as activity, so with the idle timeout on, the sandbox pauses between runs. See [long-running tasks](/docs/long-running#auto-stop).

An always-on sandbox is billed for every second it runs:

| Size | Per hour | 730 hours (a month) |
| - | - | - |
| `small` (2 vCPU, 4 GB) | $0.018 | $13.14 |
| `default` (4 vCPU, 8 GB) | $0.036 | $26.28 |

That's worth it when the jobs run often, or when something else in the sandbox has to stay up anyway. For a job that runs a few times a day, it's mostly paying for idle time.

## Schedule from outside instead

Keep the schedule on a machine that's always on, such as your own server's cron or a CI scheduler, and let the sandbox sleep between runs:

```bash
#!/bin/bash
# job.sh: needs SANDBOXER_API_KEY in the environment
set -euo pipefail
id=sb_yigyptxvgr
sbx resume "$id" --json >/dev/null || true      # already running is fine
sbx exec "$id" "echo \"outside \$(date -u +%H:%M:%S)\" >> ~/ticks.log; tail -2 ~/ticks.log"
sbx stop "$id" --wait --json >/dev/null
```

```text
outside 11:07:03
outside 11:07:14

real	0m3.783s
```

The whole resume, run and stop took 3.8 seconds, and the sandbox is billed only while it runs. `sbx exec` waits at most 600 seconds; for longer jobs use `sbx exec --detach` and poll it (see [long-running tasks](/docs/long-running#background-detached-commands)), and stop the sandbox when it's done. A job that must survive a cold boot shouldn't depend on processes left running from last time.

On your own machine, a line like `0 3 * * * /opt/jobs/job.sh` runs it nightly. That schedule line is an example; we ran the script by hand.

## Which to use

| Your job | Use |
| - | - |
| Runs while an agent is working anyway, a late run is fine | `OnCalendar=` + `Persistent=true` inside |
| Must run at a time of day, a few times a day | Your own scheduler outside, calling `sbx resume` + `sbx exec` |
| Runs every few minutes, around the clock | Keep the sandbox awake and use a timer inside |
| "Every N hours since the last run" | `OnCalendar=`, not `OnUnitActiveSec=`, unless the sandbox never stops |

## FAQ

### Do systemd timers run while a sandbox is stopped or paused?

No. Nothing runs inside a stopped sandbox, and a timer can't wake a paused one. Missed runs are handled when the sandbox comes back, as shown above.

### Does `Persistent=true` matter if the sandbox resumes with memory?

On a cold boot it's the only thing that makes a missed calendar job run. After a memory resume our `Persistent=true` timer caught up as well; we didn't test one without it across a memory resume.

### Why did my job run twice after a resume?

In our runs the guest woke with a stale clock, ran anything due then, and then the clock was corrected and wall-clock schedules caught up. Make jobs safe to run twice.

### Can cron wake a sandbox to run a job?

Not cron inside it. A cron job on another machine that calls `sbx resume` or the API can, and an API call also wakes an idle-paused sandbox by itself.

## Related

- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume)
- [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume)
- [Long-running tasks](/docs/long-running)

---

# Script the sbx CLI With JSON Output and jq

Source: https://sandboxer.com/guides/script-sbx-with-json-output-and-jq. Updated 2026-10-27.

**Short answer:** Pipe sbx and it prints JSON, so jq can read every result. Most commands print one object with a type field. sbx new and sbx resume print JSON Lines events ending in ready, so select that event for the id. Errors go to stderr as an error event, and the exit code tells you what failed.

This page is about the shape of that JSON and the shell patterns around it. For the list of commands, see the [sbx CLI cheatsheet](/guides/sbx-cli-cheatsheet). For what each error `code` means, see [API error codes](/guides/sandboxer-api-error-codes). Everything below ran on prod on 2026-10-27 with CLI build `14c740d`, `small` sandboxes and jq 1.8. Output is real, trimmed with `…`.

## The three rules

1. **JSON turns on when stdout isn't a terminal.** `sbx list | jq` works without `--json`. Add `--json` to force it in a terminal.
2. **Results go to stdout, errors to stderr.** An error is one line, `{"event":"error",…}`, on stderr. Stdout stays empty, so `$(…)` captures nothing.
3. **`sbx exec` is the exception.** Piped, it still prints the command's plain stdout and stderr. Pass `--json` explicitly to get a result object. `--detach` always prints JSON.

## What each command prints

| Command | Output when piped | Fields you'll use |
| - | - | - |
| `sbx new` | JSON Lines: `created`, a `state` line per change (may be none), `ready` | `.event`, `.id`, `.state`, `.url`, `.sandbox` |
| `sbx new --no-wait` | One line: `created` | `.id`, `.state` (`provisioning`) |
| `sbx resume` | JSON Lines: `resuming`, `state`, `ready` | `.sandbox.memoryRestored` on `ready` |
| `sbx resume --no-wait` | One line: `resuming` | `.status` |
| `sbx info` | One object, `"type":"sandbox.info"` | `.sandbox.state`, `.sandbox.labels` |
| `sbx list` | One object, `"type":"sandbox.list"` | `.sandboxes[]` (same fields as `.sandbox` above) |
| `sbx stop` | One object, `"type":"sandbox.stopping"` | `.status` (`archiving`), `.stop` |
| `sbx stop --wait` | One object, `"type":"sandbox.info"` | `.sandbox.state` (`archived`) |
| `sbx exec --json` | One object, `"type":"command.finished"` | `.exitCode`, `.stdout`, `.stderr`, `.timedOut`, `.oomKilled` |
| `sbx exec --detach` | One object, `"type":"command.started"` | `.processId`, `.logPath` |
| `sbx exec --status ` | One object, `"type":"command.status"` | `.status` (`running`, `exited`), `.exitCode` |
| `sbx delete --yes` | One object, `"type":"deletion.operation"` | `.operation.status` (`completed`) |
| `sbx status` | One object, `"type":"cli.status"` | `.signedIn`, `.apiReachable` |
| Any failure | One line on stderr, `"event":"error"` | `.code`, `.status`, `.error`, `.requestId` |

Single objects carry `ok` and `type`. Stream lines carry `event` instead. `sbx fork --wait` also streams: a `forking` line, then the same `state` and `ready` lines as resume (from the CLI source; we didn't fork here). `sbx prompt`, `sbx events` and `sbx conversations` print JSON Lines too.

## Get the id from sbx new

`sbx new` prints more than one line, so don't parse its whole output as one JSON document. Select the event you want:

```bash
id="$(sbx new --type small --name jq-demo-1 | jq -r 'select(.event == "ready") | .id')"
```

The two lines it printed, trimmed:

```json
{"event":"created","id":"sb_1xfcnnm3qr","state":"provisioning","ttlSeconds":3600,"sandbox":{"access":"owner",…}}
{"event":"ready","id":"sb_1xfcnnm3qr","state":"ready","ip":"2a01:4f9:3120:1d84::5b00:12","url":"https://fern-free-jackal.box.sandboxer.com","sandbox":{…}}
```

It took 3.2 seconds. This run had no `state` line in between, so don't count lines. Use `select(.event == "created")` with `--no-wait` when you want the id at once and plan to wait yourself.

## Exit codes in a script

`sbx` exits `0` on success, `2` for a usage error, `3` when not signed in, `4` for not found and `5` for a CLI timeout. The [cheatsheet](/guides/sbx-cli-cheatsheet) has the table. `sbx exec` passes the remote exit code through, in both modes:

```bash
# run from a script, so stdout isn't a terminal
$ sbx exec $id 'echo hello; exit 3'; echo "exit=$?"
hello
exit=3

$ sbx exec $id --json 'echo hello; echo oops >&2; exit 3'
{"ok":true,"type":"command.finished","commandId":"cmd_4znkb1hu7dph","cwd":"/home/user","durationMs":2,"exitCode":3,…,"stderr":"oops\n",…,"stdout":"hello\n",…,"success":false,"timedOut":false}
$ echo $?
3
```

So `set -e` stops your script when a remote command fails. Write `sbx exec … || true` where a failure is expected. The CLI source maps a remote timeout to `124` and a signal to `128` plus the signal number.

Put sbx flags before the command: `sbx exec $id --json --timeout 60 'cmd'`. The default timeout is 30 seconds and the maximum 600; past that, use `--detach`.

## Wait on states

Most commands wait for you: `sbx new` and `sbx resume` return at `ready`, and `sbx stop --wait` returns at `archived`. When you start something with `--no-wait` or `--detach`, poll it yourself.

Wait for a stop to finish:

```bash
sbx stop $id >/dev/null
until [ "$(sbx info $id | jq -r .sandbox.state)" = archived ]; do sleep 2; done
```

On prod, the state read `archiving` at 0 s and `archived` at 2 s.

Wait for a detached job and read its exit code:

```bash
pid="$(sbx exec $id --detach 'sleep 20; echo done; exit 7' | jq -r .processId)"
while [ "$(sbx exec $id --status $pid | jq -r .status)" = running ]; do sleep 2; done
sbx exec $id --status $pid | jq -c '{status, exitCode, stdout}'
```

```json
{"status":"exited","exitCode":7,"stdout":"done\n"}
```

The states you'll see in `.sandbox.state`, and what a wait loop should do with each:

| Group | States | In a wait loop |
| - | - | - |
| Up | `ready`, `idle`, `running` | Done waiting |
| Pending | `init`, `provisioning`, `provisioned`, `cloning` | Keep polling |
| Stopping | `archiving`, `stopping` | Keep polling |
| Stopped | `archived`, `stopped` | Done if you stopped it; resume if you need it |
| Failed | `error`, `cancelled` | Stop and report `.sandbox.error` |

After `sbx resume`, check `.sandbox.memoryRestored` on the `ready` line. Ours was `true`. When it's `false`, the sandbox booted from disk: files are there, processes aren't.

## Loop over your sandboxes by name or label

`sbx list` returns up sandboxes by default (`--all` for every state). Filter the array with jq. By name prefix:

```bash
sbx list | jq -r '.sandboxes[] | select(.name | startswith("jq-demo-")) | [.id, .state, .type, .name] | @tsv'
```

```text
sb_1xfcnnm3qr	ready	small	jq-demo-1
```

Labels are set through the REST API; the CLI doesn't have a labels command yet ([labels docs](/docs/api#labels)). Once set, every `sbx list` entry carries them:

```bash
curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/$id \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
  -d '{"labels":{"job":"nightly","repo":"web"}}' | jq -c .sandbox.labels
# {"job":"nightly","repo":"web"}

sbx list | jq -r '.sandboxes[] | select(.labels.job == "nightly") | .id'
# sb_1xfcnnm3qr
```

To act on each match, feed the ids to a `while read` loop:

```bash
sbx list | jq -r '.sandboxes[] | select(.labels.job == "nightly") | .id' |
  while read -r id; do sbx stop "$id" --wait >/dev/null && echo "stopped $id"; done
```

Only touch sandboxes your filter selects. A shared account holds other people's sandboxes too.

## Handle error events

Send stderr to a file, then branch on `.code`, not on the message. Capture `$?` in the `||` branch; inside `if ! …` it's already been negated to `0`.

```bash
out="$(sbx info sb_doesnotexist 2>err.json)" || {
  rc=$?
  case "$(jq -r .code err.json)" in
    not_found) echo "already gone (exit $rc, $(jq -r .requestId err.json))" ;;
    *)         jq -r '"failed: \(.code): \(.error)"' err.json; exit "$rc" ;;
  esac
}
```

```text
already gone (exit 4, req_KyJfdFXkiWbB3iPLleZX)
```

Errors from the API carry `code`, `status` and `requestId`. Errors the CLI raises itself have only `code`:

```json
{"event":"error","error":"Sandbox not found","code":"not_found","status":404,"requestId":"req_RRGfeKI104b7FltiW91F"}
{"event":"error","error":"--type must be small, default, large or xlarge","code":"usage"}
{"event":"error","error":"refusing to delete sb_doesnotexist without --yes in a non-interactive session","code":"usage"}
```

The first exited `4`, the other two `2`. Piped, `sbx delete` needs `--yes`.

## A complete script: one command in N fresh sandboxes

`fanout.sh` creates N `small` sandboxes without waiting, polls until each is up, runs a command in each, prints one JSON line per sandbox and deletes them all on exit, including after an error or Ctrl-C.

```bash
#!/usr/bin/env bash
# fanout.sh: run one command in N fresh sandboxes, print one JSON result per
# sandbox, and delete every sandbox it made, even on Ctrl-C or an error.
# Usage: N=2 PREFIX=ci-run ./fanout.sh 'python3 -c "print(6*7)"'
set -uo pipefail
N="${N:-2}"
PREFIX="${PREFIX:-fanout}"
CMD="${1:?usage: fanout.sh ''}"
ids=()

cleanup() {
  for id in "${ids[@]}"; do
    sbx delete "$id" --yes >/dev/null 2>&1 && echo "deleted $id" >&2
  done
}
trap cleanup EXIT

# wait_up ID: poll sbx info until the sandbox is up, fail on error states.
wait_up() {
  local id="$1" state
  for _ in $(seq 1 150); do
    state="$(sbx info "$id" | jq -r '.sandbox.state')" || return 1
    case "$state" in
      ready|idle|running) return 0 ;;
      error|cancelled)    echo "$id ended in $state" >&2; return 1 ;;
    esac
    sleep 2
  done
  echo "$id: timed out in $state" >&2
  return 1
}

# 1. Ask for all N at once; --no-wait returns after the "created" line.
for i in $(seq 1 "$N"); do
  out="$(sbx new --type small --name "$PREFIX-$i" --no-wait 2>err.json)" || {
    jq -r '"create failed: \(.code) \(.error)"' err.json >&2; exit 1; }
  ids+=("$(jq -r 'select(.event == "created") | .id' <<<"$out")")
done

# 2. Wait for every one to come up.
for id in "${ids[@]}"; do wait_up "$id" || exit 1; done

# 3. Run the command everywhere; record stdout and the remote exit code.
for id in "${ids[@]}"; do
  res="$(sbx exec "$id" --json --timeout 60 "$CMD")"; code=$?
  jq -c --arg id "$id" --argjson code "$code" \
    '{id: $id, exit: $code, stdout: (.stdout | rtrimstr("\n"))}' <<<"$res"
done

# 4. Show which sandboxes with our prefix are still up before cleanup runs.
sbx list | jq -r --arg p "$PREFIX-" \
  '.sandboxes[] | select(.name | startswith($p)) | "\(.id) \(.state) \(.name)"' >&2
```

The real run, with stderr shown:

```bash
$ N=2 PREFIX=jq-demo ./fanout.sh 'python3 -c "import platform; print(platform.python_version())"'
{"id":"sb_3tww4sm8fd","exit":0,"stdout":"3.12.3"}
{"id":"sb_xrnam6jvlw","exit":0,"stdout":"3.12.3"}
sb_xrnam6jvlw ready jq-demo-2
sb_3tww4sm8fd ready jq-demo-1
deleted sb_3tww4sm8fd
deleted sb_xrnam6jvlw
```

The whole run took 7 seconds. Afterward, `sbx info` on both ids exited `4` with `not_found`. Stdout holds only the result lines, so `./fanout.sh … > results.jsonl` gives you a clean file. Your plan limits how many sandboxes run at once and how many start per window. When step 1 fails with `limit_reached` or `rate_limited`, the error codes guide says [when to wait and when to stop](/guides/sandboxer-api-error-codes).

## FAQ

### Do I need --json when I pipe sbx?

No. JSON turns on whenever stdout isn't a terminal. The one exception is `sbx exec`, which prints the command's plain output unless you pass `--json` explicitly.

### Why does jq fail on the output of sbx new?

`sbx new` prints JSON Lines, one event per line, not one document. Filter with `jq 'select(.event == "ready")'`, which runs once per line. Avoid `jq -s` or a JSON parser that reads the whole stream at once unless you handle the array of events.

### How do I tell a missing sandbox from other failures?

Check the exit code and the error event. A missing sandbox exits `4` and its stderr line has `"code":"not_found"` and `"status":404`. Log `requestId` for anything you report to support.

### Can I filter sbx list by label on the server?

The CLI filters by state only, so filter labels with jq as shown above. The REST API filters on the server with `GET /api/v1/sandboxes?labels=job=nightly`; on prod it returned just our labeled sandbox.

## Related

- [sbx CLI cheatsheet](/guides/sbx-cli-cheatsheet)
- [Sandboxer API error codes](/guides/sandboxer-api-error-codes)
- [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox)
- [CLI reference](/docs/cli)

---

# Which Way of Starting a Service Survives Resume, Reboot and Kill?

Source: https://sandboxer.com/guides/run-a-service-that-comes-back-after-resume. Updated 2026-10-27.

**Short answer:** Every start method survives a resume that restores memory. Only an enabled systemd unit with Restart=always, or a Docker container with --restart unless-stopped, also comes back after a cold boot, a reboot and a killed process. nohup, sbx exec --detach and systemd-run are gone after any boot.

This page is the tested reference behind the "make it a service" step in our app recipes. We started the same tiny HTTP app five ways in one sandbox and broke it four ways. For what a resume restores, see [pause and resume with memory](/guides/pause-and-resume-a-sandbox-with-memory). For how an agent tells a warm resume from a cold boot, see [detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume).

## The matrix

We ran this on prod on 2026-10-27, on a `small` sandbox ($0.018/hour) that became a `default` one in step 2. Each app served one file on its own port, bound to `127.0.0.1` and hosted with `sbx host --localhost`. "Back" means the private hosted URL returned `200`. Times are from one run each, polled every 0.5 seconds from a laptop, and start when the triggering command was sent.

| Started with | 1. Resume, memory restored | 2. Cold boot (resize) | 3. `sudo reboot` | 4. `kill -9` |
| - | - | - | - | - |
| `nohup … &` | Kept, same PID | Gone | Gone | Gone |
| `sbx exec --detach` | Kept, same PID | Gone (status `lost`) | Gone | Gone |
| `systemd-run -p Restart=always` | Kept, same PID | Gone (unit `inactive`) | Gone | Back in 0.7 s |
| Enabled unit, `Restart=always` | Kept, same PID | Back in 3.8 s | Back in 11.2 s | Back in 0.6 s |
| `docker run --restart unless-stopped` | Kept, same PID | Back in 3.8 s | Back in 12.1 s | Back in 0.7 s |

- **Resume with memory:** `sbx resume` took 1.35 s, and all five URLs answered on the first poll, 1.8 s after we sent it. Nothing restarted.
- **Cold boot:** `sbx resume --type default` took 3.4 s. The unit and the container answered on the first poll after it returned.
- **Reboot:** the URL of the unit was back 11.2 s after `sudo reboot`, the container's 0.9 s later.
- **Kill:** systemd and Docker each restarted the app once (`NRestarts=1`, `RestartCount=1`).

While nothing listens on the port, the hosted URL returns `502` with a "Nothing is answering" page. That's normal during a restart. Retry until you get `200`.

## The five ways we started it

The app is Python's built-in file server. Swap in your own command.

```bash
id=$(sbx new --type small --name my-svc --json | jq -r 'select(.event == "ready") | .id')
sbx exec $id 'mkdir -p ~/www && echo ok > ~/www/index.html'

# 1. nohup
sbx exec $id 'nohup python3 -m http.server 8001 --bind 127.0.0.1 --directory /home/user/www > ~/web1.log 2>&1 &'

# 2. a detached command
sbx exec $id --detach 'python3 -m http.server 8002 --bind 127.0.0.1 --directory /home/user/www'

# 3. a transient systemd unit
sbx exec $id 'sudo systemd-run --unit web3 --uid user -p Restart=always python3 -m http.server 8003 --bind 127.0.0.1 --directory /home/user/www'

# 5. Docker with a restart policy
sbx exec $id --timeout 300 'docker run -d --name web5 --restart unless-stopped -p 127.0.0.1:8005:8005 -v /home/user/www:/www:ro python:3.12-alpine python -m http.server 8005 --directory /www'
```

```text
Running as unit: web3.service; invocation ID: 8b47e1f12ddb42179735a13c5a965bb4
…
e6025768dffda4a246d6fd390087f4481bcd1c15e26fccce98d735299c8df44f
```

The `nohup` process outlived the `sbx exec` call that started it. [`systemd-run`](https://www.freedesktop.org/software/systemd/man/latest/systemd-run.html) makes a transient unit: `Restart=always` works on it, but the unit isn't written to disk, so a boot forgets it. Inside the container the app binds `0.0.0.0`, and `-p 127.0.0.1:8005:8005` keeps it on localhost in the sandbox.

## The minimal unit file that survives all four

This is method 4, as we ran it:

```bash
sudo tee /etc/systemd/system/web4.service >/dev/null <<'UNIT'
[Unit]
Description=Tiny web app
After=network-online.target
Wants=network-online.target

[Service]
User=user
ExecStart=/usr/bin/python3 -m http.server 8004 --bind 127.0.0.1 --directory /home/user/www
Restart=always

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now web4
```

```text
enabled
active
Created symlink /etc/systemd/system/multi-user.target.wants/web4.service → /etc/systemd/system/web4.service.
```

Each line has a job:

| Line | Without it |
| - | - |
| `WantedBy=multi-user.target` + `enable` | The unit runs now but doesn't start on the next boot |
| `Restart=always` | A crash or kill leaves it stopped ([`Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=)) |
| `User=user` | It runs as root, and files it writes in `/home/user` belong to root |

Put it on a URL once. The route and its token are kept across stop, resume and reboot:

```bash
sbx host $id 8004 --localhost
```

```json
{"ok":true,"type":"port.hosted","sandboxId":"sb_jfvxy0tfuo","port":8004,"url":"https://direct-broad-otter-8004.box.sandboxer.com?_token=…","access":"private","isProtected":true,"localhost":true}
```

Without the token, the URL returned `401`. With it, `200`.

To see why a unit restarted, read its journal with `sudo`. As `user` you see the app's own output only, not systemd's messages. Sandboxes created before 10:44 UTC on 2026-10-27 also need `-m`; it does no harm on newer ones:

```bash
sbx exec $id 'sudo journalctl -m -u web4 --no-pager -n 4'
```

```text
Oct 27 10:50:05 direct-broad-otter systemd[1]: web4.service: Main process exited, code=killed, status=9/KILL
Oct 27 10:50:05 direct-broad-otter systemd[1]: web4.service: Failed with result 'signal'.
Oct 27 10:50:05 direct-broad-otter systemd[1]: web4.service: Scheduled restart job, restart counter is at 1.
Oct 27 10:50:05 direct-broad-otter systemd[1]: Started web4.service - Tiny web app.
```

## How we broke it, and what to check

**1. Resume with memory.** Stop, then resume:

```bash
sbx stop $id --wait
sbx resume $id
sbx info $id --json | jq -c '.sandbox | {memoryRestored, resumeReason}'
```

```json
{"memoryRestored":true,"resumeReason":null}
```

All five PIDs were unchanged and `uptime -s` still showed the original boot time.

**2. Cold boot.** A resume boots from disk when it can't restore memory: on a different CPU type, or when you change the size. The size change is the one you can trigger:

```bash
sbx stop $id --wait
sbx resume $id --type default
sbx info $id --json | jq -c '.sandbox | {type, memoryRestored, resumeReason}'
```

```json
{"type":"default","memoryRestored":false,"resumeReason":"resized"}
```

Moving to a smaller size isn't supported yet, so this leaves you on the bigger size. Test it on a throwaway sandbox. The detached command reported its fate:

```bash
sbx exec $id --status 559
```

```json
{"ok":true,"type":"command.status",…,"pid":559,"processId":559,"running":false,…,"status":"lost",…}
```

**3. Reboot.** This tests the same boot path and keeps the size:

```bash
sbx exec $id 'sudo reboot'
```

```text
{"event":"error","error":"The machine did not answer.","code":"sandbox_direct_failed","status":502,…}
```

That error is expected: the agent that ran the command went down with the VM. A reboot inside the guest doesn't change `memoryRestored`. It still read `false` / `resized` from the last resume, so use the boot id to spot reboots (see [detect a cold boot](/guides/detect-a-cold-boot-after-resume)).

**4. Kill.** We killed all five apps at once:

```bash
sbx exec $id 'sudo pkill -9 -f "http.server 800[1-5]"'
sbx exec $id 'systemctl show web3 web4 -p Id -p NRestarts; docker inspect web5 --format "RestartCount={{.RestartCount}}"'
```

```text
NRestarts=1
Id=web3.service

NRestarts=1
Id=web4.service
RestartCount=1
```

## Which one to use

| You want | Use |
| - | - |
| A job that ends, and you check its exit code | `sbx exec --detach` ([long jobs](/guides/run-a-long-job-in-a-cloud-sandbox)) |
| A quick server for this session only | `nohup` or `sbx exec --detach` |
| A server that must survive anything short of deletion | An enabled unit with `Restart=always` |
| An app that ships as an image | `docker run --restart unless-stopped`, or Compose with `restart: unless-stopped` |

Under Docker's [`unless-stopped` policy](https://docs.docker.com/engine/containers/start-containers-automatically/), a container you stop yourself with `docker stop` stays stopped, even after a reboot.

## FAQ

### Do processes started with nohup survive a sandbox stop?

Yes, when the resume restores memory: all five of our apps kept their PIDs. They don't survive a cold boot or a reboot, because nothing starts them again.

### How do I force a cold boot to test my service?

Run `sudo reboot` inside the sandbox; it keeps the size. From outside, `sbx resume --type ` always boots from disk, but you can't go back to the smaller size afterwards.

### How long until the hosted URL works after a reboot?

In our run, 11.2 seconds after `sudo reboot` for the systemd unit and 12.1 seconds for the Docker container, on a `default` sandbox. Until then the URL returns `502` "Nothing is answering"; retry.

### Does systemd-run with Restart=always survive a reboot?

No. It restarted the app after `kill -9` in 0.7 seconds, but after the cold boot and the reboot the unit was gone. Write a unit file and `enable` it instead.

## Related

- [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume)
- [Pause and resume a sandbox with memory](/guides/pause-and-resume-a-sandbox-with-memory)
- [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Services that should always run](/docs/long-running#services-that-should-always-run)

---

# How Do I Limit CPU and Memory for One Run Inside a Sandbox?

Source: https://sandboxer.com/guides/limit-cpu-and-memory-for-untrusted-code-inside-a-sandbox. Updated 2026-10-27.

**Short answer:** Wrap the run in a systemd scope, sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=100% -p TasksMax=256 -p RuntimeMaxSec=60. On a real sandbox a memory hog died with exit 137, a fork bomb stalled at 200 tasks, and the agent's own processes kept running. Use timeout, ulimit or a loop-mounted disk for narrower caps.

The sandbox is the security boundary: each one is a microVM with its own kernel ([why that matters](/guides/container-vs-microvm-for-untrusted-code)). Limits inside it solve a different problem. If your agent runs inside the sandbox and executes generated code there too, one bad run can eat all the RAM, CPU or disk the agent itself needs. This page bounds one run so the rest of the sandbox keeps working.

Everything below ran on a `small` sandbox (2 vCPU, 4 GB RAM) on 2026-10-27, as the default `user` with `sudo`. The kernel has cgroup v2 with the `cpu`, `io`, `memory` and `pids` controllers enabled, and systemd 255.

## Which tool caps what

| Tool | What it caps | What we measured when the cap hit |
| - | - | - |
| `systemd-run --scope -p MemoryMax=` | RAM for the run and all its children | Killed with exit 137 at the cap; the rest of the sandbox kept about 3 GB free |
| `-p TasksMax=` | Processes and threads | A fork bomb stuck at 200 tasks; `fork: Resource temporarily unavailable` |
| `-p CPUQuota=` | CPU time per second | 50% gave 5.0 s of CPU in 10 s of wall time |
| `-p RuntimeMaxSec=` | Wall time, for every process in the scope | Exit 143; a `setsid` child was killed too |
| `-p IOWriteBandwidthMax=` | Disk write rate | 200 MB at 20.1 MB/s instead of 4.0 GB/s |
| `timeout` | Wall time of one process tree | Exit 124 (137 with `-k` if it ignores SIGTERM); a `setsid` child escaped |
| `ulimit` / `prlimit` | Per-process address space, CPU seconds, file size | `MemoryError` (exit 1), exit 137 or 152 for CPU, exit 153 for file size |
| `nice` | CPU share under contention | 98.5% against 1.3% for a `nice -n 19` spinner on the same core |
| Loop-mounted scratch disk | Total bytes the run can write | `No space left on device` at 1 GB; the root disk still had 12 GB free |

Exit 137 is not always out of memory: `ulimit -t` also ends with SIGKILL. To tell them apart and to fix a sandbox that is simply too small, see [Fix exit code 137 or a full disk](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox).

## A wrapper that bounds one run

Put every limit in one scope. The scope is a cgroup, so the limits cover the run's children and grandchildren, and stopping it kills all of them:

```bash
cat > ~/run-bounded.sh <<"EOF"
#!/bin/bash
# usage: run-bounded.sh   [args...]
secs=$1; shift
exec sudo systemd-run --scope --quiet --uid="$(id -u)" --gid="$(id -g)" \
  -p MemoryMax=1G -p MemorySwapMax=0 \
  -p CPUQuota=100% -p TasksMax=256 \
  -p IOWriteBandwidthMax="/dev/vda 50M" \
  -p RuntimeMaxSec="$secs" \
  nice -n 10 "$@"
EOF
chmod +x ~/run-bounded.sh
```

`--scope` runs the command in the foreground, so your agent gets its output and exit code as usual. `--uid`/`--gid` drop back from root to `user`. The run keeps the working directory and `HOME`: `run-bounded.sh 60 bash -c 'pwd; id -un; echo HOME=$HOME'` printed `/home/user`, `user`, `HOME=/home/user`. `CPUQuota=100%` is one vCPU's worth, so on a 2 vCPU `small` the agent always has the other one. `/dev/vda` is the root disk (`findmnt -no SOURCE /`).

To see what the rest of the sandbox felt, we ran a heartbeat next to each test: a Python loop that sleeps 100 ms and records the longest gap between ticks.

```python
# ~/heartbeat.py: stands in for the agent's own process
import time, sys
last = time.monotonic(); worst = 0
end = last + float(sys.argv[1])
while time.monotonic() < end:
    time.sleep(0.1)
    now = time.monotonic(); worst = max(worst, now - last); last = now
print(f"heartbeat: worst gap {worst*1000:.0f} ms")
```

## Memory: a hog stops at MemoryMax

The hog allocates 64 MB at a time, up to 5 GB, more than the sandbox has:

```python
# ~/hog.py
chunks = []
for i in range(80):
    chunks.append(bytearray(64 * 1024 * 1024))
    print(f"{(i + 1) * 64} MB", flush=True)
print("done")
```

```bash
python3 heartbeat.py 12 &
~/run-bounded.sh 60 python3 hog.py | tail -1; echo "exit=${PIPESTATUS[0]}"
wait
```

```text
960 MB
exit=137
heartbeat: worst gap 100 ms
```

The hog died at its 1 GB cap and the heartbeat never missed a tick. `dmesg` says which kind of OOM it was. In a separate run with a 256 MB cap:

```text
Memory cgroup out of memory: Killed process 562 (python3) total-vm:280012kB, anon-rss:261376kB, … UID:1000
```

`Memory cgroup out of memory` means your cap fired. A plain `Out of memory` means the whole sandbox ran out. We ran the same hog with no scope for comparison: it took 3712 MB before the kernel killed it (exit 137), and the heartbeat's worst gap grew to 336 ms. The kernel picked the hog that time because it was the largest process, but with no cap the whole sandbox ran short of memory first, and you don't choose which process dies.

`MemorySwapMax=0` is a guard in case you add a swap file later. Sandboxes have no swap by default.

## Processes: a fork bomb guarded by TasksMax

Never run a fork bomb without a cap. This test started one inside a scope capped at 200 tasks, looked at the sandbox for 10 seconds, then killed the scope:

```bash
cat > ~/bomb-test.sh <<"EOF"
#!/bin/bash
# fork bomb, capped at 200 tasks by its own scope
sudo systemd-run --scope --unit=bomb --uid=user --gid=user -p TasksMax=200 \
  bash -c ":(){ :|:& };:" >/dev/null 2>&1 &
sleep 10
systemctl show bomb.scope -p TasksCurrent -p TasksMax
echo "processes in the sandbox: $(ps -e --no-headers | wc -l)"
start=$(date +%s%N); python3 -c "print(sum(range(10**6)))"
echo "outside the scope, python3 took $(( ($(date +%s%N)-start)/1000000 )) ms"
sudo systemctl kill --signal=KILL bomb.scope; sudo systemctl stop bomb.scope
sleep 1
echo "bomb.scope: $(systemctl is-active bomb.scope)"
echo "processes in the sandbox: $(ps -e --no-headers | wc -l)"
EOF
chmod +x ~/bomb-test.sh && ~/bomb-test.sh
```

```text
TasksCurrent=200
TasksMax=200
processes in the sandbox: 297
499999500000
outside the scope, python3 took 26 ms
bomb.scope: inactive
processes in the sandbox: 97
```

The same Python one-liner took 20 ms on the idle sandbox, so a capped bomb cost the rest of the sandbox a few milliseconds. `systemctl kill --signal=KILL` on the scope cleared all 200 processes at once; killing a bomb one PID at a time doesn't work because it keeps forking.

Don't rely on `ulimit -u` for this. It counts every process of that user, so if the agent and the untrusted code both run as `user`, the cap starves the agent too. `TasksMax` counts only the scope.

## CPU: CPUQuota and nice

```bash
time sudo systemd-run --scope --quiet --uid=user --gid=user -p CPUQuota=50% \
  timeout 10 python3 -c "while True: pass"; echo exit=$?
time timeout 10 python3 -c "while True: pass"; echo exit=$?
```

```text
exit=124
real	0m10.042s
user	0m5.027s
exit=124
real	0m10.002s
user	0m9.998s
```

With the quota, a spin loop got exactly half a CPU. `CPUQuota` is a hard cap: the run is throttled even when the CPU is otherwise idle. If you only want the agent to win when both are busy, use `nice` instead. Two spinners pinned to the same core for 9 seconds:

```bash
taskset -c 0 timeout 10 python3 -c "while True: pass" &
taskset -c 0 nice -n 19 timeout 10 python3 -c "while True: pass" &
sleep 9; ps -o pid,ni,time,pcpu,args -p $(pgrep -d, -f "while True"); wait
```

```text
    PID  NI     TIME %CPU COMMAND
…
   1817   0 00:00:08 98.5 python3 -c while True: pass
   1818  19 00:00:00  1.3 python3 -c while True: pass
```

The wrapper uses both: a quota as the ceiling and `nice -n 10` so the run yields first inside it.

## Wall time: timeout versus RuntimeMaxSec

`timeout` is the simplest guard and returns 124 when it fires. A run that ignores SIGTERM needs `-k`:

```bash
timeout 3 sleep 60; echo "exit=$?"
timeout -k 2 3 bash -c 'trap "" TERM; sleep 60'; echo "exit=$?"
```

```text
exit=124
exit=137
```

But `timeout` only signals its own process group. A child that calls `setsid` escapes it. A scope's `RuntimeMaxSec` stops every process in the cgroup:

```bash
timeout 3 bash -c "setsid sleep 301 & sleep 60"; echo "timeout exit=$?"
sleep 1; echo "left behind: $(pgrep -cx sleep)"; pkill -x sleep
sudo systemd-run --scope --quiet --unit=job --uid=user --gid=user -p RuntimeMaxSec=3 \
  bash -c "setsid sleep 302 & sleep 60"; echo "scope exit=$?"
sleep 1; echo "left behind: $(pgrep -cx sleep)"
```

```text
timeout exit=124
left behind: 1
scope exit=143
left behind: 0
```

From outside the sandbox, `sbx exec --timeout` caps a synchronous command at up to 600 seconds ([CLI reference](/docs/cli)); the scope is for runs your agent starts from inside.

## ulimit and prlimit: per-process caps without root

`ulimit` needs no `sudo` and applies to one process and what it starts. Run it in a subshell so it doesn't stick to your shell:

```bash
(ulimit -v 524288; python3 hog.py 2>&1 | grep -v "^ " | tail -3)
(ulimit -t 3; python3 -c "while True: pass"); echo "exit=$?"
(ulimit -S -t 3; ulimit -H -t 6; python3 -c "while True: pass"); echo "exit=$?"
(ulimit -f 102400; dd if=/dev/zero of=big.bin bs=1M count=500 status=none); echo "exit=$?"
```

```text
448 MB
Traceback (most recent call last):
MemoryError
exit=137
exit=152
exit=153
```

- `ulimit -v` limits address space, so the program gets a clean `MemoryError` (exit 1) instead of a kill. It also breaks programs that reserve large virtual ranges (JVMs, Go and Node.js runtimes often do), and it doesn't add up across child processes. `MemoryMax` is the safer memory cap.
- `ulimit -t` sets the soft and hard CPU-seconds limits together, and the kernel sent SIGKILL (137). With a lower soft limit the process gets SIGXCPU first (152) and can clean up.
- `ulimit -f` caps the size of each file (in 1 KB blocks): `dd` stopped at exactly 104857600 bytes with SIGXFSZ (153). It doesn't cap the total across many files.

`prlimit` sets the same limits on a command, or on a process that is already running:

```bash
prlimit --as=536870912 --cpu=3 python3 hog.py 2>&1 | tail -1; echo "exit=${PIPESTATUS[0]}"
python3 -c "while True: pass" & p=$!; prlimit --pid $p --cpu=2:2; wait $p; echo "exit=$?"
```

```text
MemoryError
exit=1
exit=137
```

## Disk: a loop-mounted scratch directory

`ulimit -f` caps one file. To cap everything a run writes, give it a scratch directory backed by a fixed-size filesystem:

```bash
sudo fallocate -l 1G /var/tmp/scratch.img
sudo mkfs.ext4 -q /var/tmp/scratch.img
sudo mkdir -p /scratch && sudo mount -o loop /var/tmp/scratch.img /scratch
sudo chown user:user /scratch
dd if=/dev/zero of=/scratch/fill.bin bs=1M count=5000 status=none; echo "dd exit=$?"
df -h / /scratch
echo hello > ~/note.txt && echo "write to home: ok"
```

```text
dd: error writing '/scratch/fill.bin': No space left on device
dd exit=1
Filesystem      Size  Used Avail Use% Mounted on
/dev/root        19G  6.4G   12G  35% /
/dev/loop0      974M  907M  196K 100% /scratch
write to home: ok
```

The run filled its 1 GB and stopped. The root disk kept 12 GB free, so the agent could still write. Run the code with `/scratch` as its working directory, and delete and recreate the image between runs for a clean slate. The mount is not in `/etc/fstab`, so mount it again after a sandbox boots from disk ([how to tell](/guides/detect-a-cold-boot-after-resume)). Code that can write anywhere else, such as `/tmp` or your home directory, isn't capped by this, so combine it with a separate user or a read-only bind mount if that matters.

To limit how fast a run writes rather than how much, use `IOWriteBandwidthMax`. Writing 200 MB with `dd ... oflag=direct`:

```text
with -p IOWriteBandwidthMax="/dev/vda 20M":  209715200 bytes … copied, 10.434 s, 20.1 MB/s
without:                                     209715200 bytes … copied, 0.0526342 s, 4.0 GB/s
```

We also tried `ionice -c3`, and it made no measurable difference. The root disk uses the `mq-deadline` scheduler (`cat /sys/block/vda/queue/scheduler`), and I/O priority classes only take effect under the BFQ scheduler ([ionice man page](https://man7.org/linux/man-pages/man1/ionice.1.html)). Use the cgroup's bandwidth cap instead.

## When to use a second sandbox instead

The limits above protect the agent's resources. They don't hide the agent's files, environment or network from the run. If the code is untrusted in the security sense, run it in its own `--no-env` sandbox, with none of your secrets in it, and delete it after: see [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call) and [Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely). To cap what many sandboxes cost in total, see [Cap what agent sandboxes can cost](/guides/cap-what-agent-sandboxes-can-cost).

## FAQ

### Do I need root to use systemd-run --scope?

A system scope needs root. Inside a sandbox you are an ordinary user with `sudo` ([machines docs](/docs/machines)). `--uid` and `--gid` then run the command as `user`, so the run itself doesn't get root. Without `sudo`, `ulimit`, `prlimit`, `nice` and `timeout` still work.

### Why did my capped run exit 143 and not 124?

143 is SIGTERM (128 + 15). That is what `RuntimeMaxSec` sends when a scope runs out of time. `timeout` instead exits with 124 by itself when it fires. Both mean the wall-time limit was hit.

### Does a scope's MemoryMax count page cache?

Yes. cgroup v2 charges file cache to the cgroup too, but the kernel reclaims cache before it kills anything, so a run that reads big files slows down rather than dying. The kills we saw came from memory the run itself allocated.

### Will the limits survive a stop and resume?

We didn't test a stop in the middle of a capped run. A scope is created per run and isn't saved anywhere, so after a boot from disk it is gone along with its processes; the wrapper script on disk is kept and applies the same limits to the next run. The loop mount needs to be mounted again too.

## Related

- [Fix exit code 137 or a full disk in a sandbox](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox)
- [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call)
- [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code)
- [Machines and sizes](/docs/machines)

---

# Build a Background Coding Agent That Pushes a Branch for Review

Source: https://sandboxer.com/guides/build-a-background-coding-agent-that-pushes-a-branch-for-review. Updated 2026-10-27.

**Short answer:** Run a small service with a task queue and a few workers. Each worker starts a sandbox from a named snapshot of your repo, runs the agent and the tests there, pulls the commit back as a git bundle, pushes the branch with the service's own credentials for review, reports status, and deletes the sandbox.

This page is about the service around the agent: the queue, the workers, status, the hand-off of the code, and cleanup. For the steps inside one run (scoped GitHub tokens, issue text as a file, `gh pr create`), see [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox). For prompting several agents by hand, see [Run coding agents in parallel](/guides/run-coding-agents-in-parallel).

What we ran on prod on 2026-10-27: every script on this page, with one task end to end. Two things are stand-ins, and we say so where they appear. The agent is a script, not a model. The "origin" is a bare git repo we created on the machine that runs the service, because we had no GitHub token for a throwaway repo, so the run pushed a branch but opened no pull request.

## At a glance

| Part | What it is here | What you'd use in production |
| - | - | - |
| Front door | `POST /tasks` on `127.0.0.1:8700` | Your chat app's webhook or slash command, calling the same endpoint |
| Queue and workers | Python `queue.Queue` and `WORKERS` threads | A durable queue (a database table, Redis, SQS) |
| Template | Named snapshot with the repo checked out | The same, plus your dependencies ([cached by lockfile hash](/guides/cache-dependencies-in-a-named-snapshot)) |
| Work area | One `small` sandbox per task, `--no-env --no-snapshots --ttl 1800` | The same |
| Agent | `agent-step.sh`, a script | Claude Code, Codex, OpenHands or your own loop |
| Code hand-off | `git bundle` copied out with `sbx scp` | The same |
| Push and PR | The service pushes to a local bare repo; no PR opened | The service pushes to GitHub, then can run `gh pr create` (not tested here) |
| Status | `GET /tasks/` plus a `reply()` hook | The same, with `reply()` posting to the chat thread |
| Our run | 14.3 s from queued to done, sandbox deleted | |

## The one design decision: who holds the push credential

The sandbox runs code you didn't write, from instructions you didn't write. So in this design the sandbox never gets a GitHub token. It commits, writes a [git bundle](https://git-scm.com/docs/git-bundle) of only the new commits, and the service copies that file out with `sbx scp`. The service checks the bundle with `git bundle verify`, fetches the branch from it into its own clone, and pushes with its own credentials.

| | Token in the sandbox (the [issue-to-PR guide](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)) | Token only in the service (this page) |
| - | - | - |
| What a misbehaving agent can push | Any branch the token allows | Nothing; the service pushes one branch name it chose |
| Can the agent open or edit PRs itself | Yes | No |
| Extra moving parts | None | One bundle file per task |
| Good for | One trusted job, run by hand | A service that takes tasks from many people |

The service also never runs anything the sandbox produced. It only reads git objects from the bundle.

## 1. A throwaway origin and the template snapshot

`setup-remote.sh` made a bare repo, `remote.git`, with a tiny Python project (`textkit.py` with `slugify()` and two unit tests), and a clone of it, `mirror`, that the service works from. With GitHub, `mirror` is a normal clone of your repo.

`build-snapshot.sh` puts the current `main` into a sandbox and saves it as the template. The repo goes in as a bundle too, so the sandbox needs no access to your git host at all:

```bash
#!/usr/bin/env bash
# Build the named snapshot every task starts from: a checkout of main in ~/repo.
# Usage: ./build-snapshot.sh  
set -euo pipefail
snap=$1
git -C mirror fetch -q origin
git -C mirror bundle create -q "$PWD/base.bundle" origin/main
id=$(sbx new --type small --no-env --name "$2" --json | jq -r 'select(.event == "ready") | .id')
trap 'sbx delete "$id" --yes >/dev/null; echo "deleted builder $id"' EXIT
echo "builder $id"
sbx scp base.bundle "$id:/home/user/" >/dev/null
sbx exec "$id" --timeout 120 "git init -q repo && cd repo && git fetch -q ~/base.bundle refs/remotes/origin/main:refs/remotes/origin/main && git checkout -q -B main origin/main && git log --oneline -1 && python3 -m unittest -q 2>&1 | tail -1"
sbx snapshot "$id" "$snap" >/dev/null
sbx snapshot info "$snap" --json | jq -c '{name: .snapshot.name, status: .snapshot.status, type: .snapshot.type}'
```

```bash
time ./build-snapshot.sh bgagent-base bgagent-1
```

```text
builder sb_lapqk7usd6
1053b3b textkit: slugify
OK
{"name":"bgagent-base","status":"ready","type":"small"}
deleted builder sb_lapqk7usd6

real	0m8.360s
```

Our first attempt used `git clone ~/base.bundle` and failed with `warning: You appear to have cloned an empty repository`. A bundle made from `origin/main` carries `refs/remotes/origin/main` and no `HEAD`, so `git clone` finds no branch to check out. `git init` plus an explicit `git fetch` of that ref works.

A named snapshot saves the disk and is independent of the builder, so the `trap` deletes the builder right away ([Snapshots](/docs/snapshots#named-snapshots-templates)). Rebuild the template when your dependencies change. Between rebuilds, each task fetches today's `main` from a fresh bundle, so a stale template only costs a slightly bigger fetch.

## 2. The service

`agentd.py` is the whole service: Python standard library, `sbx` and `git`. It needs `sbx` signed in on the machine that runs it.

```python
#!/usr/bin/env python3
"""agentd: a background coding agent service in one file (Python stdlib + sbx + git).

POST /tasks {"text": "..."}  queue a task (stands in for a chat message)
GET  /tasks/             status, sandbox, branch, PR link, step timings
Each task: sandbox from a named snapshot -> agent step -> tests -> branch
-> the service pushes it -> optional PR (GH_REPO) -> sandbox deleted.
"""
import json, os, queue, subprocess, tempfile, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SNAPSHOT = os.environ.get("SNAPSHOT", "agent-base")  # named snapshot with ~/repo
PREFIX = os.environ.get("PREFIX", "agent")           # sandbox names: PREFIX-
WORKERS = int(os.environ.get("WORKERS", "2"))        # sandboxes at once
MIRROR = os.environ.get("MIRROR", "mirror")          # local clone of origin
GH_REPO = os.environ.get("GH_REPO")                  # owner/repo: open a real PR
AGENT_STEP = os.path.abspath("agent-step.sh")

tasks, jobs, lock, git_lock = {}, queue.Queue(), threading.Lock(), threading.Lock()


def sh(*cmd, timeout=900):
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    if p.returncode != 0:
        tail = "\n".join((p.stdout + p.stderr).strip().splitlines()[-15:])
        raise RuntimeError(f"{cmd[0]} {cmd[1]} exited {p.returncode}:\n{tail}")
    return p.stdout


def update(t, status=None, **fields):
    with lock:
        if status:
            t["status"] = status
            t["steps"].append([round(time.time() - t["t0"], 1), status])
        t.update(fields)


def reply(t):
    """Stand-in for posting back to the chat thread the task came from.
    Fixed after the prod run and checked locally only (see step 5)."""
    if t["status"] == "done":
        msg = f"PR ready: {t['pr'] or t['branch'] + ' @ ' + t['commit']}"
    else:
        msg = f"Failed while {t['steps'][-2][1]}: {t['error'].splitlines()[-1]}"
    print(f"[chat] {t['id']}: {msg}", flush=True)


def run(t):
    sid, branch = None, f"agent/{t['id']}"
    work = tempfile.mkdtemp(prefix=f"{t['id']}-")
    try:
        update(t, "creating")
        out = sh("sbx", "new", "--from", SNAPSHOT, "--type", "small", "--no-env",
                 "--no-snapshots", "--ttl", "1800", "--name", f"{PREFIX}-{t['n']}", "--json")
        sid = next(e["id"] for e in map(json.loads, out.splitlines()) if e.get("event") == "ready")
        update(t, sandbox=sid)

        # Bring the snapshot's checkout up to today's main, then hand over the task.
        with git_lock:
            sh("git", "-C", MIRROR, "fetch", "-q", "origin")
            sh("git", "-C", MIRROR, "bundle", "create", "-q", f"{work}/base.bundle", "origin/main")
        with open(f"{work}/TASK.md", "w") as f:
            f.write(t["text"] + "\n")
        for name in ("base.bundle", "TASK.md"):
            sh("sbx", "scp", f"{work}/{name}", f"{sid}:/home/user/")
        sh("sbx", "scp", AGENT_STEP, f"{sid}:/home/user/")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "120",
           "git fetch -q ~/base.bundle refs/remotes/origin/main:refs/remotes/origin/main"
           f" && git switch -q -C {branch} origin/main")

        update(t, "agent")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "600", "bash ~/agent-step.sh")
        update(t, "testing")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "600", "python3 -m unittest -q")

        # The sandbox commits and exports a bundle; it never holds a push credential.
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "120",
           "git add -A && git -c user.name=agentd -c user.email=agentd@example.com"
           f" commit -q -F ~/TASK.md && git bundle create -q ~/out.bundle origin/main..{branch}")
        sh("sbx", "scp", f"{sid}:/home/user/out.bundle", f"{work}/")
        update(t, "pushing")
        with git_lock:
            sh("git", "-C", MIRROR, "bundle", "verify", "-q", f"{work}/out.bundle")
            sh("git", "-C", MIRROR, "fetch", "-q", f"{work}/out.bundle", f"+{branch}:{branch}")
            sh("git", "-C", MIRROR, "push", "-q", "-f", "origin", branch)
            commit = sh("git", "-C", MIRROR, "rev-parse", "--short", branch).strip()
        pr = None
        if GH_REPO:  # needs GH_TOKEN in the service's environment, not the sandbox's
            pr = sh("gh", "pr", "create", "--repo", GH_REPO, "--head", branch,
                    "--title", t["text"][:72], "--body", f"Task {t['id']}: {t['text']}").strip()
        update(t, "done", branch=branch, commit=commit, pr=pr)
    except Exception as e:
        update(t, "failed", error=str(e))
    finally:
        if sid:
            subprocess.run(["sbx", "delete", sid, "--yes"], capture_output=True)
            gone = subprocess.run(["sbx", "info", sid], capture_output=True).returncode != 0
            with lock:
                t["deleted"] = gone
        reply(t)


def worker():
    while True:
        run(jobs.get())


class API(BaseHTTPRequestHandler):
    def send(self, code, body):
        data = json.dumps(body, indent=1).encode()
        self.send_response(code)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):
        if self.path != "/tasks":
            return self.send(404, {"error": "not found"})
        body = json.loads(self.rfile.read(int(self.headers["content-length"])))
        with lock:
            n = len(tasks) + 1
            t = {"id": f"t{n}", "n": n, "text": body["text"], "status": "queued",
                 "t0": time.time(), "steps": [[0, "queued"]], "sandbox": None,
                 "branch": None, "commit": None, "pr": None, "error": None}
            tasks[t["id"]] = t
        jobs.put(t)
        self.send(202, {"id": t["id"], "status": "queued", "url": f"/tasks/{t['id']}"})

    def do_GET(self):
        with lock:
            if self.path == "/tasks":
                return self.send(200, [{k: t[k] for k in ("id", "status")} for t in tasks.values()])
            t = tasks.get(self.path.removeprefix("/tasks/"))
            body = t and {k: v for k, v in t.items() if k not in ("n", "t0")}
        self.send(200, body) if body else self.send(404, {"error": "no such task"})

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    for _ in range(WORKERS):
        threading.Thread(target=worker, daemon=True).start()
    print(f"agentd: {WORKERS} workers, snapshot {SNAPSHOT}, on 127.0.0.1:8700", flush=True)
    ThreadingHTTPServer(("127.0.0.1", 8700), API).serve_forever()
```

How the parts map to the architecture:

- **Queue.** `POST /tasks` stores the task and puts it on `jobs`. It answers `202` at once; the chat never waits on a sandbox.
- **Workers.** `WORKERS` threads each take one task at a time, so `WORKERS` is the most sandboxes the service runs at once. Keep it at or below your plan's active limit (100 on the $20 plan; see [Limits](/docs/limits#concurrency)). Over the limit, `sbx new` fails with `429 limit_reached`; [Handle rate limits and concurrency caps](/guides/handle-rate-limits-and-concurrency-caps) covers retrying.
- **Isolation.** `--no-env` gives the sandbox none of your environment's secrets or credentials. `--no-snapshots` means no snapshot keeps the work, and stopping erases the sandbox. `--ttl 1800` stops it after 30 minutes even if the service dies before its `finally` runs.
- **Status.** Every transition is stamped with seconds since the task was queued. `GET /tasks/` returns the record; `reply()` is where you post to the chat thread.
- **Cleanup.** The `finally` block deletes the sandbox on success and failure alike, then checks `sbx info` fails, and records `deleted`.
- **Git lock.** Workers share `mirror`, so bundle, fetch and push hold `git_lock`.

## 3. The agent step

The service copies `agent-step.sh` into each sandbox and runs it in `~/repo` with the task text in `~/TASK.md`. Ours is a script with two canned edits, so the run is repeatable without a model key:

```bash
#!/usr/bin/env bash
# Scripted stand-in for a coding agent: no model runs. It reads ~/TASK.md and
# edits the checkout in the current directory. Swap the body for your agent.
set -euo pipefail
task=$(cat ~/TASK.md)
case "$task" in
  *word*count*)
    cat >> textkit.py <<'PY'


def word_count(text: str) -> int:
    """Number of whitespace-separated words."""
    return len(text.split())
PY
    cat > test_word_count.py <<'PY'
import unittest

from textkit import word_count


class TestWordCount(unittest.TestCase):
    def test_counts_words(self):
        self.assertEqual(word_count("  two   words "), 2)


if __name__ == "__main__":
    unittest.main()
PY
    ;;
  *underscore*)
    # A plausible but wrong edit: it breaks an existing test.
    sed -i 's/\[^a-z0-9\]+/[^a-z0-9_]+/' textkit.py
    ;;
  *) echo "agent: don't know how to do: $task" >&2; exit 2 ;;
esac
git status --short
```

To use a real agent, replace the body. Pass its model key per task with `-e` on `sbx new`, or use an integrated agent with `sbx prompt` ([Integrated agents](/docs/agents)). We didn't run a model for this page. For steps that can take more than 600 seconds, start them with `sbx exec --detach` and poll `sbx exec --status`, since a synchronous `sbx exec` stops at `--timeout`.

## 4. One task, end to end

Start the service, then send a task the way a chat bot would:

```bash
SNAPSHOT=bgagent-base PREFIX=bgagent-task WORKERS=1 python3 agentd.py &
curl -sS -X POST localhost:8700/tasks -d '{"text": "Add a word_count function to textkit"}'
```

```json
{
 "id": "t1",
 "status": "queued",
 "url": "/tasks/t1"
}
```

While the task was still `creating`, the worker already had a sandbox:

```bash
curl -sS localhost:8700/tasks/t1 | jq -c '{status, sandbox}'
```

```json
{"status":"creating","sandbox":"sb_47qaldghvf"}
```

When the status reached `done` (whitespace in `steps` condensed):

```bash
curl -sS localhost:8700/tasks/t1
```

```json
{
 "id": "t1",
 "text": "Add a word_count function to textkit",
 "status": "done",
 "steps": [[0, "queued"], [0.0, "creating"], [11.0, "agent"], [11.2, "testing"], [14.3, "pushing"], [14.3, "done"]],
 "sandbox": "sb_47qaldghvf",
 "branch": "agent/t1",
 "commit": "ff2ad39",
 "pr": null,
 "error": null,
 "deleted": true
}
```

`pr` is `null` because `GH_REPO` wasn't set: there was no GitHub repo to open it on. The branch was in the throwaway origin, and the sandbox was gone:

```bash
git -C remote.git log --oneline --stat main..agent/t1
sbx info sb_47qaldghvf
```

```text
ff2ad39 Add a word_count function to textkit
 __pycache__/test_textkit.cpython-312.pyc    | Bin 0 -> 1017 bytes
 __pycache__/test_word_count.cpython-312.pyc | Bin 0 -> 758 bytes
 __pycache__/textkit.cpython-312.pyc         | Bin 0 -> 736 bytes
 test_word_count.py                          |  12 ++++++++++++
 textkit.py                                  |   5 +++++
 5 files changed, 17 insertions(+)
{"event":"error","error":"Sandbox not found","code":"not_found","status":404,…}
```

Where the 14.3 seconds went:

| Step | Seconds |
| - | - |
| `sbx new --from` the snapshot, copy in the bundle, task and agent script, fetch `main`, create the branch | 11.0 |
| Agent step (script) | 0.2 |
| Tests, commit, bundle, copy the bundle out | 3.1 |
| Verify, fetch and push in the service | under 0.1 |

The delete ran after `done`, so it isn't in the total. At $0.018/hour for `small`, billed per second, this task's sandbox time cost a few thousandths of a cent. A real agent adds its own minutes and your model provider's charges.

## 5. What the run taught us

**The agent commits whatever the tests leave behind.** Look at the diff above: `git add -A` picked up `__pycache__/` files that `python3 -m unittest` wrote, because the toy repo had no `.gitignore`. A real repo usually has one, but agents generate build output, logs and caches you didn't plan for. Keep a complete `.gitignore`, or have the service reject bundles that add files matching a deny list. Human review of the branch is the last gate; don't make it the only one.

**Tests run before anything leaves the sandbox.** The second canned task, "Make slugify keep underscores", makes a plausible regex edit that breaks an existing test. We ran that step on our own machine against the same repo (not in a sandbox, to stay inside our sandbox budget for this page):

```text
 M textkit.py
…
FAILED (failures=1)
```

In the service, that non-zero exit raises in `sh()`, the task becomes `failed` with the last lines of output in `error`, nothing is pushed, and the sandbox is still deleted. `reply()` turns it into one line for the chat. We checked that formatting locally with a recorded task:

```text
[chat] t1: PR ready: agent/t1 @ ff2ad39
[chat] t2: Failed while testing: FAIL: test_underscores_become_dashes
```

The `reply()` shown in step 2 is the fixed version, checked locally as above, not the one that ran on prod. The one in our prod run crashed on the success path because a dict literal built both messages before picking one. The task had already finished and the sandbox was deleted, since cleanup runs before `reply()`. Keep cleanup ahead of notifications for that reason.

## 6. From this sketch to a real service

| Gap in `agentd.py` | Why it matters | What to do |
| - | - | - |
| Tasks live in memory | A restart loses the queue and in-flight state | Store tasks in a database; re-queue `queued` ones on start |
| Task ids restart at `t1` | After a restart, `agent/t1` would be force-pushed over an old branch | Use unique ids, such as a UUID, in branch names |
| Sandboxes in flight when the service dies | They keep running until `--ttl` stops them | On start, list sandboxes whose name starts with `PREFIX` and delete the ones no live task owns ([Clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes)) |
| No retry on `sbx new` | A `429` fails the task | Retry with the wait the error gives ([Retry API calls safely](/guides/retry-sandbox-api-calls-safely-with-idempotency-keys)) |
| `reply()` prints | The chat never hears back | Post to the thread the task came from |
| No PR without `GH_REPO` | Our run stopped at the push | Point `mirror` at GitHub, set `GH_REPO`, and give the service (not the sandbox) a `GH_TOKEN` for that repo |

**Not tested:** we did not run the `GH_REPO` path, so this page shows no pull request being opened. It calls `gh pr create --repo --head --title --body` from the service, as documented in the [gh pr create manual](https://cli.github.com/manual/gh_pr_create), and stores the URL it prints in `pr`.

## FAQ

### Why does the service push instead of the sandbox?

So the code that reads untrusted task text never holds a credential for your git host. The sandbox can only hand back commits in a bundle, and the service decides which branch they land on.

### How many tasks can run at once?

As many as `WORKERS`, up to your plan's active-sandbox limit: 100 on the $20 plan. Sandbox starts are also limited per minute, hour and day; check `sbx limits` before a burst. We ran with `WORKERS=1`.

### What happens if the service crashes in the middle of a task?

The sandbox keeps running until its `--ttl` stops it, and with `--no-snapshots` stopping erases it. To clean up sooner, delete sandboxes with your name prefix on startup.

### Do I need to rebuild the snapshot every time main changes?

No. Each task fetches the current `main` from a fresh bundle before it branches. Rebuild the snapshot when dependencies change, so installs stay out of each task.

### Can I use this without GitHub?

Yes. Our run pushed to a bare git repo on the service's machine. Any remote the service can push to works, because the sandbox never talks to it.

## Related

- [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)
- [Run coding agents in parallel](/guides/run-coding-agents-in-parallel)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Snapshots](/docs/snapshots)

---

# Give an Agent a Bigger Sandbox Mid-Task Without Losing Its Work

Source: https://sandboxer.com/guides/give-an-agent-a-bigger-sandbox-mid-task. Updated 2026-10-27.

**Short answer:** Yes, if the work is on disk. Stop the sandbox, then sbx resume --type with a bigger size. In our test that took about 6 seconds, and files came back while processes and memory did not (memoryRestored false, resumeReason resized). Have the agent checkpoint to disk so it can continue after the cold boot.

This page is about the agent's side of a resize: what its task needs to look like so a bigger machine is a short pause, not a restart from zero. For diagnosing exit code 137 or a full disk, see [fix exit code 137 or a full disk](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox). For telling a cold boot from a memory resume, see [detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume).

## What survives a resize

We ran all of this on prod on 2026-10-27: a `small` sandbox, a task killed halfway for lack of memory, then a stop and `sbx resume --type default`.

| | After `sbx resume --type default` |
| - | - |
| Files in your home directory (the checkpoint, the code) | Kept |
| Packages you installed, the disk's contents | Kept, and the disk grows to the new size (`df` showed 56G, 51G free) |
| Running processes (the task, a `python3 -m http.server` we left running) | Gone. Nothing listened on port 8000 after the resume |
| Anything only in memory: variables, caches, the agent's in-process state | Gone |
| Files in `/tmp` | Gone |
| Sandbox id, subdomain, URL | Kept (`smooth-simple-tapir` before and after) |
| `memoryRestored` / `resumeReason` | `false` / `"resized"` |

| Step | Time (one run each) |
| - | - |
| `sbx stop --wait` | 2.2 s |
| `sbx resume --type default` (until it answers commands) | 3.4 s |
| `sbx fork --type default --wait` of the stopped sandbox | 3.4 s |
| The whole grow-and-continue loop below, including about 10 s of the task's own work | 18.6 s |

A resize is a stop and a cold boot. There's no way to add memory to a running sandbox, so the task has to survive losing its process.

## Make the task checkpoint to disk

The rule: after each unit of work, write what you've done to a file in the home directory, and on start, skip anything already recorded. Write the checkpoint atomically, so a kill in the middle of a write leaves the old file or the new one, never half of one. Here is the test task. Its `embed` step holds 5 GB at once, more than a `small` sandbox's 4 GB:

```python
# ~/task/task.py: a multi-step job that checkpoints after every step.
import json, os, sys, time

STATE = os.path.expanduser("~/task/state.json")
STEPS = ["fetch", "parse", "index", "embed", "report"]

def load():
    try:
        with open(STATE) as f:
            return json.load(f)
    except FileNotFoundError:
        return {"done": [], "results": {}}

def save(state):
    tmp = STATE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(state, f)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, STATE)  # atomic: a crash leaves the old or the new file, never half of one

def run(step):
    if step == "embed":  # the heavy step: holds 5 GB at once
        blocks = [bytearray(256 * 1024 * 1024) for _ in range(20)]
        return f"{len(blocks) * 256} MB"
    time.sleep(2)  # stands in for real work
    return "ok"

state = load()
for step in STEPS:
    if step in state["done"]:
        print(f"skip {step} (checkpointed)", flush=True)
        continue
    print(f"run  {step}", flush=True)
    state["results"][step] = run(step)
    state["done"].append(step)
    save(state)
print("all steps done", flush=True)
```

On the `small` sandbox, the kernel killed it at the heavy step. The three finished steps were already on disk:

```bash
sbx exec $id "python3 ~/task/task.py; echo exit=\$?; cat ~/task/state.json"
```

```text
run  fetch
run  parse
run  index
run  embed
exit=137
{"done": ["fetch", "parse", "index"], "results": {"fetch": "ok", "parse": "ok", "index": "ok"}}
```

For a coding agent, the same idea means: commit or save work to files as you go, keep the plan and a progress log in a file (for example `~/task/PROGRESS.md`), and put long-lived services in enabled systemd units so the cold boot starts them ([services that should always run](/docs/long-running#services-that-should-always-run)). The agent's conversation lives in your orchestrator, outside the sandbox, so it isn't affected. Only what the agent kept in the sandbox's memory is lost.

## Let the orchestrator grow the sandbox and continue

`sbx exec` returns the command's exit code, so the loop that drives the agent can react to 137 by moving up one size and running the task again:

```bash
#!/usr/bin/env bash
# grow.sh : run the task; if the kernel kills it for memory, move up one size and continue.
id=$1
bigger() { case $1 in small) echo default ;; default) echo large ;; large) echo xlarge ;; esac; }

while true; do
  sbx exec "$id" --timeout 600 "python3 ~/task/task.py"
  code=$?
  [ "$code" -eq 137 ] || exit "$code"          # 0 = done; anything else is not a memory problem

  size=$(sbx info "$id" --json | jq -r .sandbox.type)
  to=$(bigger "$size")
  [ -n "$to" ] || { echo "already at the largest size"; exit 137; }
  echo "OOM-killed on $size; moving to $to"

  sbx stop "$id" --wait >/dev/null
  sbx resume "$id" --type "$to" >/dev/null
  sbx info "$id" --json | jq -c '.sandbox | {type, memoryGB, memoryRestored, resumeReason}'
done
```

Run on a fresh `small` sandbox with the task copied in:

```bash
time ./grow.sh $id
```

```text
run  fetch
run  parse
run  index
run  embed
OOM-killed on small; moving to default
{"type":"default","memoryGB":8,"memoryRestored":false,"resumeReason":"resized"}
skip fetch (checkpointed)
skip parse (checkpointed)
skip index (checkpointed)
run  embed
run  report
all steps done

real	0m18.605s
```

Some notes from the runs:

- Read the new size from `type` and `memoryGB`. In our run on October 27, 2026, `stats` briefly still held the sample from before the stop (`memoryTotalMb: 3914`, the `small` figure); since a fix later that day, list views no longer show that stale sample after a resize.
- Exit code 137 means SIGKILL, and memory is the usual cause, not the only one. If you want to be sure before paying for a bigger size, check `dmesg | grep -i 'out of memory'` first (the [137 guide](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox) covers this).
- `xlarge` needs the $100 plan or higher ([machines](/docs/machines)), so cap `bigger` at the sizes you can use.
- Give the `sbx exec` a `--timeout` longer than the task. The default is 30 seconds. For tasks longer than a few minutes, use `sbx exec --detach` and poll, as in [run a long job](/guides/run-a-long-job-in-a-cloud-sandbox).

## Resize in place, or fork a bigger copy

A resize is one-way. Moving to a smaller size isn't supported yet: to go smaller, create a new sandbox of that size and copy your files. If you're not sure the agent should stay big, fork instead. A fork copies the disk into a new sandbox at the size you ask for, and leaves the original as it was.

We forked the stopped `small` sandbox (with its three-step checkpoint) to `default`:

```bash
sbx fork $id --type default --ttl 1800 --wait --json | tail -1 \
  | jq -c '{event, id, type: .sandbox.type, memoryGB: .sandbox.memoryGB, memoryRestored: .sandbox.memoryRestored}'
sbx info $id --json | jq -c '.sandbox | {state, type}'
```

```text
{"event":"ready","id":"sb_dz2cxig1dv","type":"default","memoryGB":8,"memoryRestored":null}
{"state":"archived","type":"small"}
```

The fork had the checkpoint, ran `embed` and `report`, and finished. The original stayed stopped at `small`. A fork's `memoryRestored` is `null` because a fork never carries memory. It took the source's name with ` (fork)` appended; CLI builds from October 27, 2026 on accept `sbx fork --name ` to name it yourself.

| | Resize in place (`resume --type`) | Fork a bigger copy (`fork --type`) |
| - | - | - |
| Keeps the sandbox id and URL | Yes | No: new id, new subdomain, new SSH host keys |
| Can go back to the old size | No | Yes: the original is untouched |
| Results end up | In the same sandbox | In the fork. Copy back what you need (see [move files in and out](/guides/move-files-in-and-out-of-a-sandbox)) |
| Auto-stop | Keeps the sandbox's setting unless you pass `--ttl` | Default one hour unless you pass `--ttl` |
| Cleanup | None | Delete whichever copy you don't need: both count toward your plan's included storage while stopped |

Use the fork when the bigger size is an experiment, or when only one step needs it and you want the rest of the task to go back to the cheaper size. Use the resize when the agent will need the memory for the rest of the task anyway.

## What the bigger size costs

Billing is per second while a sandbox runs, at the size it is running at, and a stopped sandbox costs nothing ([billing](/docs/billing)). From the price list:

| Size | RAM | Per hour | 10 minutes | 2 hours |
| - | - | - | - | - |
| `small` | 4 GB | $0.018 | $0.003 | $0.036 |
| `default` | 8 GB | $0.036 | $0.006 | $0.072 |
| `large` | 16 GB | $0.072 | $0.012 | $0.144 |

Each step up doubles the rate. Take an agent that runs 2 hours, where only a 10-minute step needs 8 GB. Running all 2 hours on `default` costs $0.072. Running on `small` and resizing to `default` when the heavy step starts at the 1-hour mark costs $0.018 for the first hour plus $0.036 for the second, $0.054 in total, because everything after the resize runs at the doubled rate. Forking a `default` copy for just the 10-minute step, then deleting it, keeps the rest at `small`: $0.033 for the 110 small minutes plus $0.006 for the fork, $0.039 in total. The roughly 6 seconds of stop and resume are not a meaningful cost.

## FAQ

### Can I add memory without stopping the sandbox?

No. The size changes only on resume or fork, and a resume at a new size always boots from disk. Plan for the task's process to end, and checkpoint to disk.

### Can I use --require-memory to keep processes during a resize?

No. `--require-memory` can't be combined with a size change, and the API answers `400 invalid_require_memory`. The memory image belongs to the old size, so a resized sandbox always cold-boots.

### Can the agent resize its own sandbox from inside?

Not in one step, because stopping the sandbox ends the agent's process. The stop and resume have to come from something outside it, such as your orchestrator. Have the agent exit with a clear code or write a request to a file, and let the orchestrator do the resize.

### Will the agent's open files and tmux sessions come back?

No. Files on disk come back. Open file handles, shells, `tmux` panes and anything else in memory don't. `/tmp` was empty after our resize, so don't keep checkpoints there.

## Related

- [Fix exit code 137 or a full disk in a sandbox](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox)
- [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume)
- [Fork a running sandbox](/guides/fork-a-running-sandbox-with-memory)
- [Machines and sizes](/docs/machines)

---

# How Long Can an AI Agent's Sandbox Run, and How Do I Keep It Alive?

Source: https://sandboxer.com/guides/how-long-can-an-agent-sandbox-run. Updated 2026-10-28.

**Short answer:** A Sandboxer sandbox auto-stops one hour after it starts unless you change that. Set a deadline up to 30 days, renew it while the job runs, or turn it off with --no-auto-stop. A stop keeps disk and memory unless snapshots are off.

This page lists every limit that can end an agent's run, what each one leaves behind, and a tested way to keep a job alive past the default hour without leaving a sandbox running forever. How to start and watch a detached job is in [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox). How idle pause and wake work is in [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use).

## Every limit that can end a run

| Limit | Default | Range | What happens when it's hit | Comes back by itself? |
| - | - | - | - | - |
| Synchronous command (`sbx exec`) | 30 s in the CLI | Up to 600 s (`--timeout`) | That command ends; the sandbox keeps running | Not applicable |
| Idle pause (`idleStopMinutes`) | 30, 60 or 120 minutes by plan | `0` (off) to `10080` (7 days) | Stop with memory, reason `idle` | Yes, on the next API call, hosted-URL request or `sbx ssh` |
| Auto-stop (`ttlSeconds`, `archiveAfter`) | 1 hour | 1 s to 30 days, or never | Stop with memory, reason `auto_stop` | No, run `sbx resume` |
| Balance at zero, auto-refill off | 24-hour grace | | Running sandboxes stop, memory and disk kept | No, top up, then `sbx resume` |
| Snapshots off (`--no-snapshots`) | Snapshots on | | Any stop, including auto-stop, ends the sandbox for good | No, it can't be resumed |
| Zero data retention (account setting) | Off | | A stop deletes the sandbox's data | No |

Two things in that table are easy to miss. Idle pause doesn't fire while a command runs, detached ones included, so a busy agent never pauses. Auto-stop is a hard deadline: a running job doesn't hold it off. And no limit on the list deletes a sandbox you could resume: a stop keeps it for 14 days after it last stopped, unless snapshots are off or zero data retention is on.

Sources: [Long-running tasks](/docs/long-running), [Billing](/docs/billing#running-out), [Limits and errors](/docs/limits).

## The edges of the deadline, tested

We ran these on a `small` sandbox on prod on October 27, 2026. A new sandbox gets `ttlSeconds: 3600` and a deadline one hour out:

```json
{"state":"ready","ttlSeconds":3600,"archiveAfter":"2026-10-27T16:52:05.717483Z","idleStopMinutes":30,"createdAt":"2026-10-27T15:52:05.717563Z"}
```

The CLI refuses a TTL over 30 days:

```bash
sbx extend $id --ttl 2592001
```

```json
{"event":"error","error":"--ttl must be between 1 and 2592000 seconds","code":"usage"}
```

The REST API doesn't refuse it. It caps it. We sent `"ttlSeconds": 99999999` with `PATCH /api/v1/sandboxes/{id}` at 15:52:19 and got 30 days back:

```json
{"ttlSeconds":2592000,"archiveAfter":"2026-11-26T15:52:19.99896Z"}
```

`0` isn't "never" on the REST API. It's an error, and `null` is the value that turns auto-stop off:

```json
{"code":"invalid_ttl","message":"ttlSeconds must be between 1 and 2592000, or null.","status":400}
```

`sbx extend $id --no-auto-stop` sends `null`. After it, `sbx info` showed `{"ttlSeconds":null,"archiveAfter":null}`. The idle limit has its own ceiling: `sbx idle $id 10081` answered `minutes must be a whole number from 0 to 10080`, and `10080` was accepted.

## The agent inside can't extend its own deadline

An agent running inside the sandbox has `SANDBOXER_TOKEN`, a key limited to that sandbox. We tried to extend the deadline with it from inside:

```bash
sbx exec $id 'printf "header = \"Authorization: Bearer %s\"\n" "$SANDBOXER_TOKEN" |
  curl -sS -K - -X PATCH "$SANDBOXER_API_URL/sandboxes/$SANDBOXER_ID" \
    -H "Content-Type: application/json" -d "{\"ttlSeconds\": 600}"'
```

```json
{"code":"api_key_action_forbidden","error":{"code":"api_key_action_forbidden","details":{"action":"sandbox.update"},"message":"This API key does not allow sandbox.update.","status":403}, …}
```

That's by design: the in-sandbox token can run commands, read and write files and host ports, but it can't change the sandbox's settings or stop it ([API keys](/docs/api-keys#the-in-sandbox-key)). A looping agent can't keep itself alive. The deadline belongs to whatever started the sandbox: your orchestrator, your CI job or you.

## Keep a long job alive with a lease

You have three ways to cover a job longer than an hour:

| Approach | How | If your orchestrator dies | Use when |
| - | - | - | - |
| Fixed deadline | `sbx new --ttl 28800` | Stops at the deadline | You know roughly how long the job takes |
| Lease | Renew a short TTL while the job runs (below) | Stops within one TTL | The length is unknown, and a forgotten sandbox must not keep billing |
| No deadline | `sbx new --no-auto-stop` | Runs until someone stops it | A long-lived worker or service; pair it with idle pause |

A lease is a short TTL that your orchestrator keeps pushing forward while the job is alive. When the job ends, or the orchestrator crashes, renewals stop and the sandbox auto-stops on its own. This is the script we ran. It runs where your API key lives, not in the sandbox:

```bash
#!/bin/bash
# lease.sh ID PID TTL EVERY
# While process PID runs in sandbox ID, keep its auto-stop TTL seconds ahead,
# renewing every EVERY seconds. Stop renewing when the job ends, or when this
# script dies, and the sandbox auto-stops on its own within TTL seconds.
id=$1 pid=$2 ttl=$3 every=$4
while :; do
  status=$(sbx exec "$id" --status "$pid" --json | jq -r '.status // "unknown"')
  case $status in exited|killed|lost) break ;; esac
  sbx extend "$id" --ttl "$ttl" --json |
    jq -r --arg s "$status" '"\(now | todate) job \($s), auto-stop at \(.sandbox.archiveAfter)"'
  sleep "$every"
done
echo "$(date -u +%FT%TZ) job $status, lease released"
```

An `unknown` status (a failed API call) counts as alive, so one network error doesn't drop the lease.

We started a 66-minute job, longer than the default hour, on the sandbox above, set its TTL to 10 minutes, and held a lease that renewed every 4 minutes:

```bash
sbx extend $id --ttl 600
pid=$(sbx exec $id --detach 'for i in $(seq 1 66); do echo "minute $i/66 $(date -u +%T)"; sleep 60; done; echo "job finished"' | jq -r .processId)
./lease.sh $id $pid 600 240
```

```text
2026-10-27T15:53:21Z job running, auto-stop at 2026-10-27T16:03:21.695251Z
2026-10-27T15:57:22Z job running, auto-stop at 2026-10-27T16:07:22.000686Z
…
2026-10-27T16:49:25Z job running, auto-stop at 2026-10-27T16:59:25.978264Z
2026-10-27T16:53:26Z job running, auto-stop at 2026-10-27T17:03:26.289082Z
2026-10-27T16:57:26Z job running, auto-stop at 2026-10-27T17:07:26.596142Z
2026-10-27T17:01:26Z job exited, lease released
```

The deadline was never more than 10 minutes away, yet the job ran past the 16:52 default it was created with:

```json
{"status":"exited","exitCode":0,"startedAt":"2026-10-27T15:53:17.18676567Z","finishedAt":"2026-10-27T16:59:17.313608726Z", …}
```

With no more renewals, the sandbox stopped itself 4 seconds after the last deadline:

```json
{"state":"archived","stop":{"endedAt":"2026-10-27T17:07:30.47246Z","reason":"auto_stop","status":"completed"}}
```

The job's output stayed readable while the sandbox was stopped, through command history:

```bash
sbx history $id cmd_1sax2d0pucdo | tail -2
```

```text
minute 66/66 16:58:17
job finished
```

The whole run, 75 minutes of `small` time from create to auto-stop, came to 2,262 billable seconds of `default` time, which `sbx usage $id` priced at $0.02262.

Tune the two numbers to your job. The sandbox can run up to `TTL + EVERY` past the job's end; ours ran 8 minutes after the job finished. Add `sbx stop $id` after the loop if you want it to stop right away. Keep `EVERY` well under `TTL` so one missed renewal doesn't stop a live job. If a renewal is missed anyway, the stop keeps memory, and `sbx resume` usually brings the job back where it froze: see [what happens to a job if the sandbox stops](/guides/run-a-long-job-in-a-cloud-sandbox).

## When snapshots are off, the deadline is the end

A sandbox created with `--no-snapshots` keeps nothing, so its auto-stop is final. We created one with a 60-second TTL. Its `idleStopMinutes` came back as `0`, because an idle pause would discard it:

```json
{"id":"sb_lye4sci5f1","snapshots":false,"ttlSeconds":60,"idleStopMinutes":0,"archiveAfter":"2026-10-27T17:08:55.796031Z"}
```

It stopped with `"reason":"auto_stop"` about 35 seconds after its deadline. Then:

```bash
sbx resume $id
```

```json
{"event":"error","error":"This sandbox was created with snapshots off and cannot be resumed.","code":"snapshots_disabled","status":409}
```

Use snapshots off only for work whose results leave the VM before the deadline. Give such a sandbox a TTL with room to spare. The stop isn't exact to the second: ours came 4 and 35 seconds after the deadline.

## What keeping one up for a day costs

Billing is per second while a sandbox runs. A stopped one costs no compute, and its disk and memory image count toward your plan's included storage (50 GB on the $20 plan). From the [pricing](/pricing) rates:

| Size | Per hour | 24 hours | 7 days around the clock | 730 hours (a month) |
| - | - | - | - | - |
| `small` (2 vCPU, 4 GB) | $0.018 | $0.43 | $3.02 | $13.14 |
| `default` (4 vCPU, 8 GB) | $0.036 | $0.86 | $6.05 | $26.28 |
| `large` (8 vCPU, 16 GB) | $0.072 | $1.73 | $12.10 | $52.56 |
| `xlarge` (16 vCPU, 32 GB) | $0.20 | $4.80 | $33.60 | $146.00 |

A `small` sandbox running all month fits inside the $20 plan's included time. With a lease or idle pause, you pay only for the hours the job actually runs, plus the slack you choose.

## FAQ

### What's the longest a sandbox can run?

As long as you keep it running. One deadline can be at most 30 days ahead, and `sbx extend` can push it again at any time. `--no-auto-stop` removes it.

### Does a sandbox get deleted when it auto-stops?

No. It's stopped with its disk and memory, and `sbx resume` brings it back. It's kept for 14 days after it last stopped: we email you on day 11, before the data is deleted on day 14, and a resume restarts the 14 days, counting from the next stop. After that it stays listed as expired, and resume and fork answer `409 snapshot_data_expired`. Sandboxes created before October 29, 2026 are kept until you delete them. The other exceptions are sandboxes created with `--no-snapshots`, which can't be resumed, and accounts with zero data retention turned on, where a stop deletes the data.

### Can my agent keep its own sandbox alive?

Not with the in-sandbox `SANDBOXER_TOKEN`. Extending answers `403 api_key_action_forbidden`. Renew the deadline from the process that created the sandbox, as in the lease above.

### Does a running job stop the sandbox from pausing?

It stops idle pause, because a running command counts as activity. It doesn't hold off auto-stop. Set a TTL longer than the job, or renew it while the job runs.

### What if my balance runs out mid-job?

New creates and resumes are refused, but running sandboxes get a 24-hour grace period. After that they stop with memory and disk kept, and resume once you top up or turn on auto-refill.

## Related

- [Run a long job in a cloud sandbox and check on it later](/guides/run-a-long-job-in-a-cloud-sandbox)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Persistent or ephemeral sandboxes for agents](/guides/persistent-or-ephemeral-sandboxes-for-agents)
- [Long-running tasks](/docs/long-running)

---

# How Do I Keep an API Key Out of an Agent's Sandbox?

Source: https://sandboxer.com/guides/keep-api-keys-out-of-an-agent-sandbox. Updated 2026-10-27.

**Short answer:** Run a small proxy on your own machine that holds the real key and adds it to each request. Expose it inside the sandbox with sbx forward --reverse, and give the agent only a proxy token you can revoke. The key never enters the VM, and every call is logged on your side.

[Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely) covers how to put a secret into a sandbox when the agent needs it. This page is for when the agent should be able to *use* a key but never *hold* it. An agent runs as `user` with `sudo`, so it can read anything you put in the VM. The only safe place for the key is outside the VM. If what you want to hold back is the agent's own Sandboxer access, so a human approves each command, see [Require human approval before an agent runs a command](/guides/require-human-approval-before-an-agent-runs-a-command).

## How it fits together

```text
your machine                                     sandbox (the agent)
------------------------------------------       ---------------------------------
keyproxy.py on 127.0.0.1:7777                    PROXY_URL=http://127.0.0.1:7777
  holds UPSTREAM_API_KEY                         PROXY_TOKEN=
  checks the proxy token     <-- sbx forward --reverse --local 7777 --
  allows a few paths
  adds "Authorization: Bearer "
  logs every call to calls.log
        |
        v
  https://httpbin.org  (stands in for the real API)
```

`sbx forward --reverse` carries the port over the same tunnel as `sbx ssh`. Nothing becomes public, and the proxy listens only on your loopback. So the proxy token works only from inside that sandbox, through that tunnel. If the agent copies the token somewhere else, it is useless there.

We used [httpbin.org](https://httpbin.org) as the API. Its `/bearer` endpoint returns `401` without an `Authorization: Bearer` header, and `/headers` echoes back the headers it got. That shows the key really was added, without a real third-party key.

## The proxy (about 50 lines, Python standard library)

Save this as `keyproxy.py` on your machine or on a server you control. Never put it in the sandbox.

```python
#!/usr/bin/env python3
"""keyproxy: holds the real API key, injects it, logs every call.
Run it on your machine or server, never in the sandbox."""
import json, os, sys, time, urllib.error, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

UPSTREAM = "https://httpbin.org"             # the API you're protecting
ALLOW = ("/bearer", "/headers", "/anything/") # paths the agent may call
REAL_KEY = os.environ["UPSTREAM_API_KEY"]     # read once; never sent to the sandbox
TOKENS = "tokens.json"                        # {"": "