# Limits

https://instabox.sh/docs/limits.md

What each plan allows, and the machine specs. Account and GET /v1/usage show yours.

| What | Default limit |
|---|---|
| Machines | Free: 1, in any state (paused and stopped ones count). Pro: 500, 100 running at once. Team: unlimited, 500 running at once |
| Machine size | 4 vCPUs and 8 GiB of memory by default, the only size on the free plan. Pro: 2/4, 4/8 or 8/16. Team: 2/4 up to 32 vCPUs / 64 GiB. `GET /v1/orgs` lists each workspace's as `allowedSpecs` |
| Snapshots | Free: 5. Pro and Team: unlimited |
| Storage | Free: 200 GB for your snapshots and machine files, counted hourly. Pro and Team: no cap; storage beyond the monthly credit is billed per GiB-hour ([Pricing](https://instabox.sh/docs/pricing.md)) |
| API keys | 20 active |
| `exec` | 32 requests at once across your sandboxes, shared by commands, file reads, desktop, port-forward and Secret refresh calls (more get a retryable `429 quota_exceeded`); 10 minutes per command (`--timeout`, 60 seconds by default); an `exec --background` job has no exec timeout, but the machine's idle pause and `max-run` still apply |
| Idle pause | Free: 30 minutes, fixed. Paid plans: 5 to 30 minutes, or off |
| Labels | 32 per machine |
| Public ports | 16 per machine |

### Machine specs
Every template but `ci` (Ubuntu 24.04) is Debian 13 (trixie), amd64, with systemd, on Linux 6.18.54 (`uname -r`: `6.18.54-instabox`) in a Firecracker microVM. A machine has 4 vCPUs and 8 GiB of memory by default (sizes above; `free -m` shows about 7.8 GiB) and a 20 GiB persistent ext4 disk at `/data` (`/dev/vdb`; home is `/data/home`). The ext4 root disk (`/`) depends on the template. Every template has git, curl, jq, Python 3.13 with `python3 -m venv` and pip, and passwordless `sudo` with apt for anything else. On `agent-browser`, `universal` and `t3`, `/data` itself belongs to the machine's user (build in `/data/workspace` or a new `/data/<dir>` without `sudo`), the JDK is OpenJDK 21 (Debian 13 has no 17; `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`), `zip` and `unzip` are installed, and the Rust toolchain in `/opt/rust` is the user's: `rustup toolchain install`, `rustup default` and `rustup target add` need no `sudo`, and a rustup of your own in `~/.cargo` takes over `cargo` and `rustc`.

| Template | OS, kernel | vCPU / RAM | Root disk | `/data` | Also preinstalled |
|---|---|---|---|---|---|
| `agent-browser` (the default) | Debian 13, 6.18.54 | 4 / 8 GiB | 16 GiB | 20 GiB | gh, Node.js 22 with npm, pnpm and Yarn, Bun, uv, Go, Rust, a JDK (OpenJDK 21), zip, gcc and make, Docker with Compose, Claude Code, Codex, and the desktop (VNC, Xfce, Chromium) |
| `universal` | Debian 13, 6.18.54 | 4 / 8 GiB | 16 GiB | 20 GiB | `agent-browser`'s tools; the desktop installs on first use (about half a minute) |
| `t3` | Debian 13, 6.18.54 | 4 / 8 GiB | 16 GiB | 20 GiB | `universal`'s, plus the T3 Code server (port 3774) |
| `herdr` | Debian 13, 6.18.54 | 4 / 8 GiB | 8 GiB | 20 GiB | gh, Node.js 22 and npm, Claude Code, Codex, the desktop, and herdr (no Docker, uv, Go, Rust or C toolchain) |
| `orca` | Debian 13, 6.18.54 | 4 / 8 GiB | 8 GiB | 20 GiB | gh, Node.js 22 and npm, Claude Code, Codex, the desktop, and the Orca runtime (no Docker, uv, Go, Rust or C toolchain) |
| `ci` | Ubuntu 24.04, 6.18.54 | 2 / 4, 4 / 8 or 8 / 16 GiB, by `runs-on` label | 50 GiB | 20 GiB | For `runs-on: instabox-medium` jobs ([CI](https://instabox.sh/docs/ci.md)): GitHub's Actions runner and the tools of GitHub's `ubuntu-24.04` image (Java, Go, .NET, Node.js, Python, Ruby, PHP, Rust, Kotlin, Android SDK, Docker, Chrome, Firefox, kubectl, the AWS and Azure CLIs, a prefilled `/opt/hostedtoolcache`, ...), no desktop or coding agents |

Common CLI tools such as GNU `time` (`/usr/bin/time`), `rg` (ripgrep) and `htpasswd` are **not** preinstalled on every template. Install with apt after refreshing indexes (the image ships without apt lists, so `apt-get install` alone fails until `apt-get update` has run):
```
sudo apt-get update
sudo apt-get install -y time ripgrep apache2-utils
```
`herdr` and `orca` also omit Docker and several language toolchains (see the table). Prefer `agent-browser` or `universal` when you need Docker.

To run an older or different image, such as `node:16-buster`, use Docker inside the machine: on `agent-browser`, `universal` and `t3` the machine's user is in group `docker`, so `docker run --rm node:16-buster node -v` works without `sudo`. An operator can also build a template from a public Debian- or Ubuntu-based image; it is then listed by `instabox templates` like the others.

The free plan creates from `agent-browser` or `ci` only. `instabox templates` (`GET /v1/templates`) lists the templates you can create from. `creatable: true` means some host holds that version's rootfs and the API accepts it for create, subject to your plan (`403 template_not_allowed`); a version no host holds any more is listed `creatable: false`, and a create pinned to it is refused up front with `400 bad_request` ("version … is on no host", with the versions you can create). An older pin (`universal@2026-10-08.2`, …) is held by fewer hosts than `latest`, so it can still fail placement with `no_capacity` ("no host can fit … with template sha256:…") when those hosts are full, while the same size on `latest` creates at once. Prefer an unpinned template (or the `latest` version) unless you need that exact pin; on `no_capacity` for an old pin, create from `latest` or a snapshot, or retry later (a `400` for a version on no host is not retryable). Check a machine with `uname -r`, `cat /etc/os-release`, `nproc`, `free -m` and `df -h / /data`.

- Over the machine or storage limit, new machines and snapshots are refused until you delete some. **Account** warns from 80% of your storage.
- A machine started from a snapshot has at least the storage of the machine it was saved from.
- `exec` streams: `exec --stream` prints the output as the command writes it (over the API, `Accept: text/event-stream`; no 1 MiB cap); without it, the output arrives in one result when the command exits, each stream cut at 1 MiB. A command runs for at most 10 minutes (`--timeout`, 60 seconds by default); a longer job runs detached with `exec --background` (no exec timeout; the machine's idle pause and `max-run` still apply) and `jobs ls|logs|wait|kill` follow it.
- `exec` returns when the command exits, even if it started a server in the background (`nohup ./server >server.log 2>&1 &`); the server keeps running, and its output from later than about a second after the exit is not returned. Redirect both stdout and stderr (`>log 2>&1`): a background process still writing to exec's output after that gets a broken pipe, so it may die or a server may drop requests.

### `/tmp` and shared memory
`/tmp` is a **RAM-backed tmpfs**, not the 20 GiB `/data` disk. Its size is about **half the machine's memory** (about 2 GiB on 2 vCPU / 4 GiB, about 4 GiB on 4 / 8), with about 1,048,576 inodes. Writes consume RAM (under memory pressure they can move to swap, see Swap). A large file under `/tmp` can fail with `No space left on device` even when `df -h /data` shows free space — that is the tmpfs cap, not the persistent disk.

For big staging (docker save, caches, Bazel/Gradle fixtures, `mktemp` defaults):
- `export TMPDIR="$HOME/tmp" && mkdir -p "$TMPDIR"` (preferred; home is `/data/home`, on the persistent disk). On `agent-browser`, `universal` and `t3` the top of `/data` belongs to the box's user, so `mkdir /data/tmp` works too; on the other templates (`ci`, `herdr`, `orca`, ...) it is root-owned, so `mkdir /data/tmp` as the box's user is `Permission denied`: use `sudo mkdir -p /data/tmp && sudo chown "$USER" /data/tmp` there.
- or `sudo mount -o remount,size=… /tmp` (still RAM; only if you have headroom)

`/dev/shm` is also tmpfs. Check with `df -h /tmp /dev/shm /data`.

### File watches and open files
Default guest limits are low for large repos and parallel test runners:

| Limit | Typical default | Symptom when hit |
|---|---|---|
| `fs.inotify.max_user_watches` | about 32k–64k | watchers (chokidar, Vite, webpack) log `ENOSPC` and miss edits |
| soft `ulimit -n` (nofile) | 1024 (hard often 524288) | `EMFILE` from Jest/workers opening many files |

The two limits have different scopes. The inotify sysctls are machine-wide and last until the next restart:
```
sudo sysctl -w fs.inotify.max_user_watches=524288
sudo sysctl -w fs.inotify.max_user_instances=512
```
`ulimit -n` changes only the shell that runs it and that shell's children, so raise it in the same shell as the workload. A separate `instabox exec` is a new shell and starts at 1024 again:
```
ulimit -n 65536 && npm test       # one exec or one SSH session; up to the hard limit
```
To raise it for every session, including `instabox exec` (which runs `bash -lc`, a non-interactive login shell that never reads `~/.bashrc`), use a root-owned drop-in that every login shell sources: `/etc/profile.d/nofile.sh` with the `ulimit -n` line, or `/etc/security/limits.d/` (exec and SSH both go through `pam_limits`). `~/.bashrc` only reaches interactive shells (SSH, the Terminal tab), so it is not the fix for an `EMFILE` under `exec`. The inotify sysctls persist across restarts from a drop-in under `/etc/sysctl.d/`. A setting written to any of these applies to new sessions, not to a shell that is already open. Check with `cat /proc/sys/fs/inotify/max_user_watches` and `ulimit -n`.

### Snapshot create size and reported bytes
`instabox create --snapshot` / `Sandbox.create(snapshotId)` **without** `--vcpus` / `--mem-mib` (or SDK `vcpus` / `memMiB`) uses the caller's plan `limits.machineSpec` (often 4 vCPU / 8 GiB) — **not** the source machine's size. A snapshot of a 2/4 machine can therefore silently create a 4/8 fork unless you pass the size explicitly. `instabox fork` keeps the source size.

In snapshot JSON, `rootSizeBytes` and `dataSizeBytes` are the **logical** root and `/data` disk sizes the next create will provision (e.g. 16 GiB + 20 GiB = 36 GiB), not the sparse or compressed bytes stored while saving.

### Swap
Every machine has swap. At its first boot the guest agent creates `/data/swapfile`, the size of the machine's memory up to 4 GiB (and at most a quarter of `/data`), so a 2 vCPU / 4 GiB machine gets 4 GiB, and lists it in `/etc/fstab` (`/data/swapfile none swap sw,nofail 0 0`) so it is on again after every stop and start. Heavy link steps (CPython PGO+LTO, Rust fat LTO) that the kernel OOM killer would stop on 4 GiB of memory alone then spill into it. `free -h` and `swapon --show` show it. `swapon`, `swapoff` and `mkswap` are on the user's PATH (links in `/usr/local/bin`); changing swap takes `sudo`.

- The swapfile is part of `/data` (`df -h /data` counts it). It is made only when `/data` keeps at least 1 GiB, or a tenth of the disk if more, free besides it; otherwise the machine starts without swap and the guest agent logs why (`sudo journalctl -u instabox-guest-agent | grep swap`). After freeing space or a larger disk (`instabox resize <machine> --disk N`), the next start makes it.
- A snapshot or fork carries the swapfile and its `/etc/fstab` line; a machine from a `--root-only` snapshot makes a new one at its first boot. The swapfile holds whatever memory was paged out to it (which can include secrets a process had in memory), and anyone who can `sudo` on a machine made from the snapshot can read it. Once the machine has swapped, those pages also count toward the snapshot's stored bytes (up to the swapfile's size). Before snapshotting a machine you will share, zero it (`sudo swapoff /data/swapfile && sudo shred -n0 -z /data/swapfile && sudo mkswap /data/swapfile && sudo swapon /data/swapfile`; `swapoff` alone leaves the pages on disk), or take a `--root-only` snapshot.
- Remove it with `sudo swapoff /data/swapfile && sudo rm /data/swapfile` (deleting its `/etc/fstab` line too, if you like): it is not made again (`/data/.instabox-swapfile` records that it was made). `sudo touch /etc/instabox/no-swap` keeps the guest agent from making or turning on one.
- To make it larger (or smaller), rewrite it in place; the `/etc/fstab` line already lists it:
```
sudo swapoff /data/swapfile
sudo rm /data/swapfile
sudo fallocate -l 8G /data/swapfile
sudo chmod 600 /data/swapfile
sudo mkswap /data/swapfile
sudo swapon /data/swapfile
```

### Out-of-memory (OOM)
When a process uses more RAM than the machine has (and its swap), the **kernel OOM killer** SIGKILLs a victim inside the guest. The sandbox usually stays `running` and usable.

The API and CLI report it, from the first control release after 2026-10-29.2 (see the last bullet):
- `GET /v1/machines/{id}` (`instabox get`, `instabox ls --json`) counts the kills in `oomKills` and gives the newest as `lastOom` (`at`, `pid`, `process`, `rssBytes`, `anonRssBytes`, and `cgroup` when a memory cgroup limit, not the machine, ran out). `instabox get` shows them as `OOM kills`. They are read about once a minute, and at once after an exec fails as an OOM kill makes it fail.
- An exec whose command (or a process it started) the OOM killer killed returns `oomKilled: true` and the kill as `oomKill`, and `instabox exec` prints a warning. Its `exitCode` is usually **137**: the command runs under `bash -lc`, the OOM killer takes the child (the compiler, the linker, the test runner), and bash reports it as 128 + 9, so `signal` is unset. Only when the session leader itself is killed does the result carry `signal` (`KILL` or `KILL@openssh.com`, the SSH exit-signal name, never `SIGKILL`). Check `oomKilled`, not `signal`. It is checked only when the exit code is 137, there is a `signal`, or stderr says a process was killed (`Killed`, `signal 9`, `Error 137`). It is `false` when the kernel log lost the kill's record, and absent when the exec ends in an error instead of a result: compare `oomKills` before and after.
- `state` stays `running` and `lastError` null: an OOM kill is not a machine failure.
- A control plane from before that release leaves `oomKills` and `oomKilled` out, and so does `instabox` (CLI 0.8.32 printed them as `0` and `false`: update it). A machine whose host or template predates it reports none (`oomKills: 0`, `oomKilled: false`): its template needs a rebuild with the new guest agent. Absent or 0 does not mean no kill. Confirm from inside with `dmesg -T | grep -i oom` or `journalctl -k | grep -i oom`.

Raise the size or the swap (see Swap).

### App builds on `universal` / `agent-browser`
From the template release that ships ISF-64 (the first `agent-browser`, `universal` and `t3` versions newer than the ones `instabox templates` listed `latest` on 2026-10-28), these templates come ready for mobile and large app builds:

- **`/data` is yours.** Home is `/data/home` and `/data/workspace` is the persistent work directory; the top of `/data` belongs to the box's user too, so `mkdir /data/build` needs no `sudo`.
- **Rust: the toolchain in `/opt/rust` is yours.** `command -v cargo` is `/usr/local/bin/cargo`, a wrapper (same for `rustup`, `rustc`) that runs the toolchain under `/opt/rust` with `RUSTUP_HOME=/opt/rust/rustup`, so `exec` and non-interactive ssh find it. `/opt/rust` belongs to the box's user, so `rustup toolchain install nightly`, `rustup default nightly`, `rustup target add wasm32-unknown-unknown` and `rustup component add` work without `sudo`; `cargo install` goes to `~/.cargo/bin`. rustup itself is pinned (`rustup self update` is off). A rustup of your own (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`) takes over: while `RUSTUP_HOME` is unset and `~/.cargo/bin` has `rustup`, the wrappers run `~/.cargo/bin/cargo` and the rest, with no `PATH` change. If that rustup has no default toolchain (`--default-toolchain none`), `cargo` fails with `no default toolchain configured`: `rustup default stable`, or `RUSTUP_HOME=/opt/rust/rustup` for the system toolchain.
- **zip and unzip** are installed (Android, Flutter and React Native packaging call `zip`).
- **A JDK: OpenJDK 21.** Debian 13 has no `openjdk-17` package; `openjdk-21-jdk-headless` is installed (`java`, `javac`, `keytool`; Gradle and the Android Gradle plugin run on 21), with `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64` in `/etc/environment` and the login profile, so `exec` and non-interactive ssh (`ssh box ./gradlew assemble`) have it. For another JDK (Temurin, Zulu), install it and set `JAVA_HOME`.

A box on an older template version (`stat -c %U /opt/rust` prints `root`) keeps the old root disk, so it still needs the workaround: `sudo chown -R "$USER": /opt/rust && sudo chown "$USER": /data` and `sudo apt-get update && sudo apt-get install -y zip openjdk-21-jdk-headless` give the same setup.

### Outbound bandwidth
What a machine **sends** is capped at about **50 Mbit/s** (~6 MB/s), with little or no burst: uploads to the internet (pushing to a remote, pushing an image, posting a file), what it sends back over SSH and port forwards, and the responses a public port URL serves. That is a per-VM host limit (`vm_egress_mbit`), not your plan's monthly egress allowance. What a machine **receives** is not limited: `apt-get`, `npm install`, a model download or `instabox push` into the box run at the host's speed.

So a ~100 MB file going **out** of a box (`instabox pull`, a release upload, a large response to a request on its public URL) takes on the order of **15–20 seconds**; plan agent timeouts for upload-heavy steps accordingly, and keep large artifacts in the box (on `/data`, or in a snapshot) instead of moving them out and back in. A slow **download into** a box is not this cap; if you see one, the cause is elsewhere (the mirror, DNS, the archive's own server): report it with `instabox feedback` with the URL and the time. Raising or bursting the cap is a platform follow-up.

### Disk sizes
A machine has one disk, `/data` (home is `/data/home`), 20 GiB by default. It comes with the machine's size: at create, leave `persistentDiskGiB` out (the TypeScript SDK's `diskGiB`, the Python SDK's `persistent_disk_gib`); any other disk size is `400 spec_not_allowed`. To change its size later (`df -h /data` full?): `instabox resize <machine> --disk 50` from the CLI (add `--restart` when it must stop the machine first: a shrink, or an image that can't grow online), `PATCH /v1/machines/{id}` with `{"persistentDiskGiB": 50}` for API and SDK alike, or **Resize disk** in the machine's Settings in the dashboard. The operating system and the template's software sit on a separate system disk the template sizes; it is not counted in the disk and only admins grow it (`rootDiskGiB` from anyone else is `400 spec_not_allowed`).

- The size is 1-1024 GiB; 0 or absent leaves the disk unchanged. More than 1024 is `400 bad_request`.
- **Grow** a running machine and it grows right away, no restart, processes keep running (machines from older images too). If it can't grow online (rare: the machine is unreachable or its host is older), it's `409 grow_needs_stop`: stop it and send the size again. A stopped machine grows at its next start.
- **Shrink** only a stopped machine (`409 operation_conflict` otherwise). Your data plus 10% (at least 1 GiB) must fit; else `409 disk_too_small`, the message names the smallest size that fits, and nothing changes.
- Paid plans only: a free machine's disk is fixed (`400 spec_not_allowed`, running or stopped). The disk may grow to at most 200 GiB on Pro, 500 GiB on Builder, 1024 GiB on Team and Enterprise, within your stored-data limit (`429 quota_exceeded`). A free workspace's disk is fixed at 20 GiB: a disk already larger is kept as it is: growing or shrinking it is refused naming the 20 GiB ceiling (an admin can allow it per user).
- Billing: a paid machine's disk is billed by the second while the machine exists (running, paused or stopped) until it is deleted. The first 20 GiB of every sandbox is free; beyond that it costs $0.00011 per GiB-hour (about $0.08 per GiB-month). The system disk, snapshots and a paused machine's saved memory are not billed.
- The field is `persistentDiskGiB` in every REST body; `diskGiB` is only the TypeScript SDK's option name, and `POST /v1/machines` refuses it with `400 unknown_field`. PATCH refuses fields it does not know the same way: `{"name": "web", "diskGiB": 200}` is `400 unknown_field`, and neither the name nor the disk changes.
