This is the full developer documentation for bhatti
# Quickstart
> Install bhatti on your own machine, create a sandbox, and run something inside it. About five minutes including the install download.
This is the path I recommend for everyone except remote-CLI users sharing someone else’s bhatti server. You’ll install the daemon on a machine you own — a Linux box with KVM **or** a Mac on Apple Silicon — and from that same box create your first sandbox and run something inside it.
If you don’t have a suitable box in front of you, the [Self-hosting](/docs/self-hosting/) page covers what you need (a Raspberry Pi 5, a Hetzner box, any cloud VM with nested virtualization, or an Apple Silicon Mac).
## Install
[Section titled “Install”](#install)
```bash
curl -fsSL bhatti.sh/install | sudo bash
```
That’s the whole install. The script:
1. Downloads one self-contained runtime bundle — the `bhatti` CLI + daemon, the `bhatti-vmm` VMM helper, the `bhatti-netd` gateway, the `libkrun` (krucible) library, the lean guest kernel — plus the minimal Ubuntu 24.04 rootfs.
2. Installs and starts the service (systemd on Linux, launchd on macOS).
3. Creates an `admin` user and writes its API key + endpoint to your user’s `~/.bhatti/config.yaml`. **You don’t need to run `bhatti setup` after.** The CLI on this box is already wired up.
The transcript ends with the admin API key printed once. Save it — you’ll want it later for adding teammates or driving the server from another machine.
## Create a sandbox
[Section titled “Create a sandbox”](#create-a-sandbox)
```bash
bhatti create --name dev
```
```text
sandbox/dev created (1 vCPU, 1024 MB)
Shell: bhatti shell dev
```
Defaults are 1 vCPU and 1024 MB. Override with `--cpus` and `--memory`. See [`bhatti create`](/docs/reference/cli/sandbox/create/) for every flag.
## Run a command
[Section titled “Run a command”](#run-a-command)
```bash
bhatti exec dev -- echo hello
# → hello
```
Anything after `--` runs verbatim inside the sandbox. The `--` is optional when there’s no ambiguity:
```bash
bhatti exec dev uname -a
```
## Open a shell
[Section titled “Open a shell”](#open-a-shell)
```bash
bhatti shell dev
```
Interactive PTY, full keyboard. Press `Ctrl+\` to detach — the shell keeps running. Reconnect with `bhatti shell dev` and the scrollback is replayed.
## Publish a port
[Section titled “Publish a port”](#publish-a-port)
If you want to reach a service running inside the sandbox from the internet:
```bash
bhatti exec dev -- bash -c 'python3 -m http.server 3000 &'
bhatti publish dev -p 3000 -a my-app
# → https://my-app.bhatti.sh
```
That URL is public. The sandbox can be cold and the URL still works — the first request restores it in well under a second, then serves. See [Networking](/docs/under-the-hood/networking/) for aliases, custom domains, and how the wake-then-serve proxy works.
## Clean up
[Section titled “Clean up”](#clean-up)
```bash
bhatti destroy dev
```
Or `bhatti destroy dev -y` if you don’t want the confirmation prompt.
## What just happened
[Section titled “What just happened”](#what-just-happened)
1. **`bhatti create`** asked the daemon to boot a **krucible** microVM — a real Linux VM with its own kernel and filesystem, isolated by the CPU’s virtualization (KVM on Linux, HVF on macOS).
2. **`bhatti exec`** sent a command over the wire protocol to lohar, the guest agent running as PID 1 inside the VM.
3. **`bhatti shell`** opened a WebSocket and attached to a PTY session.
4. **`bhatti destroy`** stopped the VM, tore down its network gateway, and removed the sandbox’s copy-on-write overlay.
The sandbox was a full Linux environment, not a container. When idle, it would have paused itself (warm, memory still resident) and, after longer idle, snapshotted its memory to disk (cold) — then restored transparently on the next request.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Concepts](/docs/concepts/) — sandboxes, thermal states, the binaries
* [Self-hosting](/docs/self-hosting/) — adding teammates, custom domains, backups, the rest of the operator path
* [`bhatti exec`](/docs/reference/cli/exec/exec/) — streaming, timeouts, detach
* [`bhatti create`](/docs/reference/cli/sandbox/create/) — every flag
***
### Driving a remote bhatti from your laptop
[Section titled “Driving a remote bhatti from your laptop”](#driving-a-remote-bhatti-from-your-laptop)
If someone else runs the bhatti daemon and you only need the CLI on your local machine — or you want to install bhatti on a server and drive it from your laptop — install the CLI without sudo and point it at the server:
```bash
# Install the CLI binary
curl -fsSL bhatti.sh/install | bash
# Configure the endpoint and key
bhatti setup --url https://your-server:8080 --token bht_abc...
# or interactively:
bhatti setup
```
`bhatti setup` accepts `--url` and `--token` for non-interactive configuration (agents, CI, provisioning scripts). Without flags, it prompts. See [`bhatti setup`](/docs/reference/cli/admin/setup/) for the full reference.
The server operator gets your API key by running `bhatti user create --name ` once, on the server, and sending you the key. See [Self-hosting → adding teammates](/docs/self-hosting/#adding-teammates).
# Concepts
> Sandboxes, thermal states, and the binaries — the mental model for bhatti.
## What a sandbox is
[Section titled “What a sandbox is”](#what-a-sandbox-is)
A sandbox is a real Linux microVM — its own kernel, its own filesystem, its own network stack. Not a container: process isolation is hardware-enforced by the CPU’s virtualization, via **KVM** on Linux and **HVF** (Hypervisor.framework) on Apple Silicon. bhatti boots it with **krucible**, its own VMM (a fork of [libkrun](https://github.com/containers/libkrun)) — so the same sandbox model runs on a Linux server and on a macOS laptop.
Sandboxes are created in well under a second and destroyed instantly. When idle, they’re automatically paused — and eventually their whole memory image is snapshotted to disk and the RAM is freed — then restored transparently on the next request.
## The binaries
[Section titled “The binaries”](#the-binaries)
**bhatti** runs on the host. It’s both the server daemon (`bhatti serve`) and the CLI client (`bhatti create`, `bhatti exec`, …). The daemon is pure Go and never links the VMM. Instead it spawns, per sandbox, a small helper:
* **bhatti-vmm** — the cgo helper that *is* the VM. It links krucible (libkrun) and boots the guest; the daemon drives it (pause, resume, snapshot, restore) out-of-band over a control socket, because the libkrun entry call blocks for the life of the VM.
* **bhatti-netd** — a per-owner network gateway (a userspace [gVisor](https://gvisor.dev/) netstack). It gives the guest policed egress, isolates it from the host, and lets same-owner sandboxes reach each other — with no host TAP devices, bridges, or iptables rules. It’s on by default.
**lohar** runs *inside* the VM as PID 1. It’s the guest agent — a static binary with zero dependencies (no systemd, no libc, no initramfs). It handles command execution, file operations, and PTY sessions, and talks to the host over vsock using a custom [wire protocol](/docs/under-the-hood/wire-protocol/).
The names: *bhatti* (भट्टी) means furnace. *lohar* (लोहार) means blacksmith.
## Thermal states
[Section titled “Thermal states”](#thermal-states)
Every sandbox is in one of three thermal states:
| State | What’s happening | Resume latency | Memory cost |
| -------- | -------------------------------------- | ----------------------------------------------- | ----------- |
| **Hot** | VM is running, vCPUs active | — | Full |
| **Warm** | vCPUs paused, memory still allocated | \~4 ms | Full |
| **Cold** | Memory snapshotted to disk, VM stopped | sub-second (\~380 ms measured on Apple Silicon) | Zero |
The thermal manager transitions sandboxes automatically based on activity: hot → warm after 30 s idle, warm → cold after 30 min. From the API consumer’s perspective, every sandbox appears to be running — a request to a cold sandbox triggers a transparent restore before the operation executes.
You can opt out with `keep_hot: true` on sandbox creation or update, which prevents automatic pausing. Useful for sandboxes maintaining persistent external connections.
## Architecture at a glance
[Section titled “Architecture at a glance”](#architecture-at-a-glance)
```plaintext
CLI / API client
│
▼ HTTP (TCP or unix socket)
┌──────────────┐
│ bhatti serve │ ← host daemon: REST API, thermal manager, proxy (pure Go)
└──────┬───────┘
│ spawns per sandbox / per owner, drives over a control socket
├───────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ bhatti-vmm │ │ bhatti-netd │ ← krucible (libkrun) VMM + gVisor net gateway
└──────┬───────┘ └──────────────┘
│ vsock
▼
┌──────────────┐
│ lohar │ ← PID 1 inside the VM: exec, files, PTY sessions
└──────────────┘
```
For the full picture, see [Architecture Overview](/docs/under-the-hood/architecture/).
# API Reference
> Every HTTP endpoint with a runnable curl example, the response shape, and notable status codes.
The bhatti server exposes a REST API over HTTP. Every authenticated endpoint requires a Bearer token; unauthenticated endpoints are limited to `/health` and `/_shell/` (the embedded web-shell page).
This page is the canonical hand-curated reference. Each endpoint shows a runnable `curl` and the response shape. Anything not in the curl example uses the documented default.
## Base URL
[Section titled “Base URL”](#base-url)
| Mode | API base | Public proxy |
| ------------------------------------------------------ | --------------------------- | --------------------------------------------- |
| Default (`listen: :8080`) | `http://localhost:8080` | path-based at `/sandboxes//proxy//` |
| Path-based public proxy (`public_proxy_listen: :8443`) | `http://localhost:8080` | `http://:8443//` |
| Domain mode (`domain.api_host`, `domain.proxy_zone`) | `https://api.` | `https://.` |
## Authentication
[Section titled “Authentication”](#authentication)
All endpoints except `/health` and `/_shell/...` require a Bearer token in the `Authorization` header:
```http
Authorization: Bearer bht_abc123def456...
```
Tokens come from [`bhatti user create`](/docs/reference/cli/admin/user-create/). The CLI loads them from `~/.bhatti/config.yaml`.
**WebSocket endpoints use the same Bearer header.** There is no `?token=…` query-param auth — leaving the token out of URLs eliminates accidental logging in proxy access logs.
The web shell is the exception: the URL contains `#token=…` in the **fragment** (never sent to the server), and the embedded page extracts the token client-side and uses it as a Bearer header on the WebSocket connection.
## Response shape
[Section titled “Response shape”](#response-shape)
JSON for every endpoint. Errors use `{"error": "...", "request_id": "..."}`:
```json
{"error": "sandbox not found", "request_id": "req_a1b2c3d4"}
```
`request_id` is included on every error response; quote it when reporting bugs.
## Status codes
[Section titled “Status codes”](#status-codes)
| Code | Meaning |
| ----- | ----------------------------------------------------------------------------------------------- |
| `200` | OK. |
| `201` | Created. Used for resource-creation endpoints. |
| `202` | Accepted. Used for async tasks (image pull) — body contains a `task_id`. |
| `204` | No content. Used by `unpublish`. |
| `400` | Validation error. Body has `{"error": "..."}`. |
| `401` | Missing or invalid Bearer token. |
| `403` | Forbidden — typically a per-sandbox cap (CPU, memory) being exceeded. |
| `404` | Resource not found. |
| `409` | Conflict — name already exists, or volume is attached when an op needs it detached. |
| `413` | Request too large (file write > 100 MB). |
| `429` | Rate-limited or quota exceeded (`max-sandboxes`, `max-cpus`, etc). |
| `500` | Internal server error. |
| `501` | Endpoint exists but the server isn’t configured for it (e.g. backups without an `s3_*` config). |
| `502` | Bad gateway from the reverse proxy — the sandbox process isn’t listening on the requested port. |
## Response headers
[Section titled “Response headers”](#response-headers)
The server stamps every response with:
* `X-Bhatti-Version` — server version. CLI clients use this to detect server upgrades.
* `X-Bhatti-Min-CLI` — the minimum CLI version the server requires. Clients older than this should upgrade.
* `X-Bhatti-Existing: true` — set on `POST /sandboxes` when the request was idempotent (a sandbox with that name already existed).
## Async tasks
[Section titled “Async tasks”](#async-tasks)
Long-running operations like `POST /images/pull` return immediately with `202 Accepted` and a body like:
```json
{"task_id": "tsk_abc123", "status": "running"}
```
Poll `GET /tasks/:id` until `status` is `completed` or `failed`. The `progress` field, when present, contains a human-readable progress string the CLI can display.
***
# System
[Section titled “System”](#system)
## Health check
[Section titled “Health check”](#health-check)
```http
GET /health
```
No auth required. Lightweight check.
```bash
curl http://localhost:8080/health
```
```json
{"status": "ok", "uptime": "2h15m30s"}
```
***
# Sandboxes
[Section titled “Sandboxes”](#sandboxes)
## List sandboxes
[Section titled “List sandboxes”](#list-sandboxes)
```http
GET /sandboxes
```
Returns sandboxes owned by the authenticated user, enriched with thermal state and any published URLs. Listing does **not** wake cold sandboxes.
```bash
curl http://localhost:8080/sandboxes \
-H "Authorization: Bearer $TOKEN"
```
```json
[
{
"id": "a1b2c3d4",
"name": "dev",
"status": "running",
"ip": "",
"cpus": 2,
"memory_mb": 1024,
"image": "minimal",
"created_at": "2026-04-29T15:21:48Z",
"urls": ["https://dev-k3m9x2.bhatti.sh"]
}
]
```
## Create a sandbox
[Section titled “Create a sandbox”](#create-a-sandbox)
```http
POST /sandboxes
```
```bash
curl -X POST http://localhost:8080/sandboxes \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "dev",
"cpus": 2,
"memory_mb": 1024,
"env": {"NODE_ENV": "production"},
"init": "cd /workspace && npm install",
"image": "minimal",
"persistent_volumes": [
{"name": "workspace", "mount": "/workspace", "auto_create": false}
],
"secrets": ["OPENAI_KEY"],
"files": [
{"guest_path": "/etc/app/config.json", "content": "", "mode": "0644"}
]
}'
```
**Request fields:**
| Field | Type | Default | Notes |
| -------------------- | ------ | ---------- | ------------------------------------------------------------------------------------- |
| `name` | string | auto | Pattern: `[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}`. |
| `cpus` | float | `1` | Fractional values allowed. Capped at user’s `max_cpus_per_sandbox`. |
| `memory_mb` | int | `1024` | Capped at user’s `max_memory_mb_per_sandbox`. |
| `disk_size_mb` | int | image size | Resize the rootfs at create time. |
| `env` | object | — | Env vars baked into the config drive. |
| `init` | string | — | Boot-time script. Runs as a session named `init`. |
| `keep_hot` | bool | `false` | Disables the thermal manager for this sandbox. |
| `hugepages` | bool | `false` | 2 MB hugepages. Faster boot. |
| `image` | string | `minimal` | Image name from `GET /images`. |
| `template_id` | string | — | Create from a template. |
| `persistent_volumes` | array | — | `[{name, mount, auto_create, read_only, size_mb}]`. |
| `secrets` | array | — | Names of stored secrets to inject as env vars. **Ignored when `template_id` is set.** |
| `files` | array | — | `[{guest_path, content (base64), mode}]`. **Ignored when `template_id` is set.** |
| `new_volumes` | array | — | **Legacy** equivalent of `persistent_volumes`. The CLI no longer sends these. |
| `volumes` | array | — | **Legacy** equivalent. |
**Responses:**
* `201 Created` with the sandbox object.
* `200 OK` + `X-Bhatti-Existing: true` if a non-destroyed sandbox with that name already exists. Idempotent — safe to retry from scripts.
* `400` for invalid name / size / etc.
* `403` if `cpus` or `memory_mb` exceed the user’s per-sandbox cap.
* `429` if the user is at their `max-sandboxes` cap.
## Get a sandbox
[Section titled “Get a sandbox”](#get-a-sandbox)
```http
GET /sandboxes/:id
```
```bash
curl http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN"
```
Returns the full sandbox record. Both name and ID work as `:id`.
## Update a sandbox
[Section titled “Update a sandbox”](#update-a-sandbox)
```http
PATCH /sandboxes/:id
```
Currently only `keep_hot` is mutable. All other fields (cpus, memory, image) are immutable after creation — destroy and recreate.
```bash
curl -X PATCH http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN" \
-d '{"keep_hot": true}'
```
Setting `keep_hot: true` on a stopped sandbox immediately wakes it.
## Destroy a sandbox
[Section titled “Destroy a sandbox”](#destroy-a-sandbox)
```http
DELETE /sandboxes/:id
```
```bash
curl -X DELETE http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN"
```
```json
{"status": "destroyed"}
```
Persistent volumes attached to the sandbox are detached (not deleted). Published URLs are cleaned up.
## Stop a sandbox
[Section titled “Stop a sandbox”](#stop-a-sandbox)
```http
POST /sandboxes/:id/stop
```
Snapshot to disk and free memory. Resume with `POST /sandboxes/:id/start`. Stop takes a full memory snapshot; there are no diff snapshots.
```bash
curl -X POST http://localhost:8080/sandboxes/dev/stop \
-H "Authorization: Bearer $TOKEN"
```
## Start a sandbox
[Section titled “Start a sandbox”](#start-a-sandbox)
```http
POST /sandboxes/:id/start
```
Restore from snapshot.
```bash
curl -X POST http://localhost:8080/sandboxes/dev/start \
-H "Authorization: Bearer $TOKEN" \
-d '{"force": true}'
```
`force: true` retries even if the sandbox is in `unknown` state from a previously failed restore.
***
# Execution & shells
[Section titled “Execution & shells”](#execution--shells)
## Run a command
[Section titled “Run a command”](#run-a-command)
```http
POST /sandboxes/:id/exec
```
```bash
curl -X POST http://localhost:8080/sandboxes/dev/exec \
-H "Authorization: Bearer $TOKEN" \
-d '{"cmd": ["echo", "hello"]}'
```
```json
{"exit_code": 0, "stdout": "hello\n", "stderr": ""}
```
**Request fields:**
| Field | Type | Default | Notes |
| ------------- | ------ | ---------- | -------------------------------------------------------------------------- |
| `cmd` | array | (required) | Command and arguments. |
| `timeout_sec` | int | `300` | Max `86400` (24h). |
| `detach` | bool | `false` | Fire-and-forget. Returns `{pid, output_file, detached: true}` immediately. |
| `output_file` | string | auto | When `detach: true`, the guest path to redirect output to. |
This endpoint does **not** accept an `env` field. Env vars are baked into the sandbox’s config drive at creation time (`--env`, `--secret`). To pass per-call env vars, use the WebSocket exec path below.
### Streaming output
[Section titled “Streaming output”](#streaming-output)
Set `Accept: application/x-ndjson`:
```bash
curl -N -X POST http://localhost:8080/sandboxes/dev/exec \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/x-ndjson" \
-d '{"cmd":["npm","install"]}'
```
```json
{"type":"stdout","data":"Installing dependencies...\n"}
{"type":"stderr","data":"npm warn deprecated...\n"}
{"type":"exit","exit_code":0}
```
Each line is flushed immediately. `type` is `stdout`, `stderr`, `exit`, or `error`.
Cold sandboxes wake automatically before executing.
## Interactive shell (WebSocket)
[Section titled “Interactive shell (WebSocket)”](#interactive-shell-websocket)
```http
GET /sandboxes/:id/ws
```
Upgrade to WebSocket. Authentication via `Authorization: Bearer ...` header on the upgrade request.
| Query param | Description |
| -------------- | ------------------------------------------------------ |
| `session=` | Reattach to an existing session by ID. |
| `new=true` | Force a new session even if one is currently attached. |
**Wire protocol:**
* **Server → client (binary):** raw terminal output.
* **Server → client (text):** initial `{"type":"session","session_id":"s1"}` message; thereafter only on session events.
* **Client → server (binary):** keystrokes (forwarded to the PTY).
* **Client → server (text JSON):** `{"type":"resize","rows":N,"cols":N}` or `{"cmd":[...], "env":{}, "max_idle_sec":N}` (first message of a non-attached session — this is where per-call env injection lives).
Detach by closing the connection. The session keeps running.
## List sessions
[Section titled “List sessions”](#list-sessions)
```http
GET /sandboxes/:id/sessions
```
```bash
curl http://localhost:8080/sandboxes/dev/sessions \
-H "Authorization: Bearer $TOKEN"
```
```json
[
{"session_id": "init", "argv": "npm install", "running": true, "attached": false, "created_at": 1761792000},
{"session_id": "s1", "argv": "/bin/zsh -li", "running": true, "attached": true, "created_at": 1761792100}
]
```
## List listening ports (one sandbox)
[Section titled “List listening ports (one sandbox)”](#list-listening-ports-one-sandbox)
```http
GET /sandboxes/:id/ports
```
```bash
curl http://localhost:8080/sandboxes/dev/ports \
-H "Authorization: Bearer $TOKEN"
```
```json
[
{"sandbox_id": "a1b2c3d4", "container_port": 3000, "proxy_url": "/sandboxes/a1b2c3d4/proxy/3000/"}
]
```
The `proxy_url` is path-relative; prepend the API base URL for a full reachable URL.
## List listening ports (all sandboxes)
[Section titled “List listening ports (all sandboxes)”](#list-listening-ports-all-sandboxes)
```http
GET /ports
```
Same row shape as above; covers every sandbox the authenticated user owns.
***
# Files
[Section titled “Files”](#files)
All file operations target the same path with query parameters.
## Read a file
[Section titled “Read a file”](#read-a-file)
```http
GET /sandboxes/:id/files?path=/path
```
Returns raw file content with `Content-Type: application/octet-stream`. The `X-File-Size` header always carries the total size (so clients can detect truncation).
```bash
curl "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \
-H "Authorization: Bearer $TOKEN" \
-o app.js
```
**Server-side truncation:**
```bash
curl "http://localhost:8080/sandboxes/dev/files?path=/var/log/agent.log&offset=1&limit=2000&max_bytes=51200" \
-H "Authorization: Bearer $TOKEN"
```
| Param | Type | Description |
| ----------- | ------ | ------------------------------------ |
| `path` | string | Absolute path inside the sandbox. |
| `offset` | int | 1-indexed line number to start from. |
| `limit` | int | Max lines to return. |
| `max_bytes` | int | Max bytes to return. |
Whichever limit hits first stops the read.
## List a directory
[Section titled “List a directory”](#list-a-directory)
```http
GET /sandboxes/:id/files?path=/dir&ls=true
```
```bash
curl "http://localhost:8080/sandboxes/dev/files?path=/workspace&ls=true" \
-H "Authorization: Bearer $TOKEN"
```
```json
[
{"name": "app.js", "size": 1234, "mode": "0644", "is_dir": false, "mtime": 1761792345},
{"name": "node_modules", "size": 4096, "mode": "0755", "is_dir": true, "mtime": 1761792500}
]
```
Capped at 10 000 entries; a sentinel row indicates truncation if hit.
## Stat a file
[Section titled “Stat a file”](#stat-a-file)
```http
HEAD /sandboxes/:id/files?path=/path
```
```bash
curl -I "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \
-H "Authorization: Bearer $TOKEN"
```
Response headers:
| Header | Description |
| -------------- | ------------------------ |
| `X-File-Size` | Size in bytes. |
| `X-File-Mode` | Octal mode, e.g. `0644`. |
| `X-File-IsDir` | `true` or `false`. |
## Write a file
[Section titled “Write a file”](#write-a-file)
```http
PUT /sandboxes/:id/files?path=/path[&mode=0644]
```
Body is the raw content. `Content-Length` is required (chunked transfer-encoding is rejected).
```bash
curl -X PUT "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js&mode=0644" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Length: 25" \
--data-binary 'console.log("hello world")'
```
```json
{"status": "ok"}
```
Writes are atomic (temp file + fsync + rename). 100 MB hard cap.
***
# Networking
[Section titled “Networking”](#networking)
## Reverse proxy
[Section titled “Reverse proxy”](#reverse-proxy)
```http
ANY /sandboxes/:id/proxy/:port/*path
```
Tunnels HTTP and WebSocket through the engine into the sandbox. The path after `/proxy//` is forwarded verbatim to `localhost:` inside the VM. Bearer auth required.
Useful for development. For public, auth-free URLs, use [`publish`](#publish-a-port).
```bash
curl http://localhost:8080/sandboxes/dev/proxy/3000/api/users \
-H "Authorization: Bearer $TOKEN"
```
`502` is returned if no process is listening on the port.
## Publish a port
[Section titled “Publish a port”](#publish-a-port)
```http
POST /sandboxes/:id/publish
```
```bash
curl -X POST http://localhost:8080/sandboxes/dev/publish \
-H "Authorization: Bearer $TOKEN" \
-d '{"port": 3000, "alias": "my-app"}'
```
```json
{
"id": "pub_a1b2c3d4",
"sandbox_id": "a1b2c3d4",
"port": 3000,
"alias": "my-app",
"url": "https://my-app.bhatti.sh",
"created_at": "2026-04-30T17:00:00Z"
}
```
If `alias` is omitted, an alias is auto-generated as `-<6-hex>`. `409` is returned on alias collision.
The `url` value depends on the server config:
| Server config | URL format |
| --------------------------------- | -------------------------------------------------------------------------------------------- |
| `domain.proxy_zone: bhatti.sh` | `https://my-app.bhatti.sh` |
| `public_proxy_listen: :8443` only | `http://:8443/my-app/` |
| Neither | `(no public proxy configured) alias: my-app` — the CLI surfaces a hint to use the proxy URL. |
## List published ports
[Section titled “List published ports”](#list-published-ports)
```http
GET /sandboxes/:id/publish
```
Returns an array of publish rules with their generated URLs.
## Unpublish a port
[Section titled “Unpublish a port”](#unpublish-a-port)
```http
DELETE /sandboxes/:id/publish/:port
```
```bash
curl -X DELETE http://localhost:8080/sandboxes/dev/publish/3000 \
-H "Authorization: Bearer $TOKEN"
```
`204 No Content`.
## Mint a shell token
[Section titled “Mint a shell token”](#mint-a-shell-token)
```http
POST /sandboxes/:id/shell-token
```
Generates a new web-shell token bound to this sandbox. Each call invalidates the previous token — only one is active at a time.
```bash
curl -X POST http://localhost:8080/sandboxes/dev/shell-token \
-H "Authorization: Bearer $TOKEN"
```
```json
{
"url": "https://api.bhatti.sh/_shell/a1b2c3d4#token=k3m9x2qr...",
"token": "k3m9x2qr..."
}
```
The token is in the URL **fragment** (`#token=...`); fragments are not sent to the server. The `_shell` page extracts the token client-side and uses it as a Bearer header on the WebSocket.
## Revoke a shell token
[Section titled “Revoke a shell token”](#revoke-a-shell-token)
```http
DELETE /sandboxes/:id/shell-token
```
The previously-active token (if any) is invalidated immediately.
***
# Templates
[Section titled “Templates”](#templates)
Templates are reusable sandbox blueprints. The CLI doesn’t expose them yet; reach for the API directly.
```http
GET /templates
POST /templates
GET /templates/:id
DELETE /templates/:id
```
```bash
curl -X POST http://localhost:8080/templates \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "node-dev",
"cpus": 2,
"memory_mb": 1024,
"image": "minimal",
"env": {"NODE_ENV": "development"},
"user_data": "cd /workspace && npm install",
"secrets": ["OPENAI_KEY"]
}'
```
Use `template_id` in `POST /sandboxes` to create a sandbox from a template. Template fields are defaults; per-request fields override them. **Note:** the template path silently drops request-side `secrets` and `files` arrays — set them on the template instead, or don’t use a template.
***
# Secrets
[Section titled “Secrets”](#secrets)
Encrypted at rest with [age](https://age-encryption.org/), scoped per user.
## List secrets
[Section titled “List secrets”](#list-secrets)
```http
GET /secrets
```
Returns an array of `{name, created_at, updated_at}`. Values are never returned.
## Create or update a secret
[Section titled “Create or update a secret”](#create-or-update-a-secret)
```http
POST /secrets
```
```bash
curl -X POST http://localhost:8080/secrets \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "OPENAI_KEY", "value": "sk-..."}'
```
Re-posting with an existing name updates the value silently.
## Delete a secret
[Section titled “Delete a secret”](#delete-a-secret)
```http
DELETE /secrets/:name
```
```bash
curl -X DELETE http://localhost:8080/secrets/OPENAI_KEY \
-H "Authorization: Bearer $TOKEN"
```
***
# Volumes
[Section titled “Volumes”](#volumes)
Persistent ext4 volumes. Several operations require the volume to be detached (no active sandbox attachments) and return `409` otherwise: `delete`, `resize`, `clone` (snapshot), `restore`.
## List volumes
[Section titled “List volumes”](#list-volumes)
```http
GET /volumes
```
Returns an array of volume records including current attachments.
## Create a volume
[Section titled “Create a volume”](#create-a-volume)
```http
POST /volumes
```
```bash
curl -X POST http://localhost:8080/volumes \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "workspace", "size_mb": 5120}'
```
`429` if the user is over their storage quota.
## Get a volume
[Section titled “Get a volume”](#get-a-volume)
```http
GET /volumes/:name
```
## Delete a volume
[Section titled “Delete a volume”](#delete-a-volume)
```http
DELETE /volumes/:name
```
`409` if attached.
## Resize a volume
[Section titled “Resize a volume”](#resize-a-volume)
```http
POST /volumes/:name/resize
```
```bash
curl -X POST http://localhost:8080/volumes/workspace/resize \
-H "Authorization: Bearer $TOKEN" \
-d '{"size_mb": 10240}'
```
`new_size > current_size` only — shrinking is not supported. `409` if attached.
## Clone a volume
[Section titled “Clone a volume”](#clone-a-volume)
```http
POST /volumes/:name/snapshot
```
Independent point-in-time copy. Source must be detached.
```bash
curl -X POST http://localhost:8080/volumes/workspace/snapshot \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "workspace-pre-upgrade"}'
```
***
# Backups
[Section titled “Backups”](#backups)
Volume backups to S3-compatible storage. Requires a `backup` block in the server config; otherwise every endpoint returns `501`.
## List backups for a volume
[Section titled “List backups for a volume”](#list-backups-for-a-volume)
```http
GET /volumes/:name/backups
```
## Trigger a backup
[Section titled “Trigger a backup”](#trigger-a-backup)
```http
POST /volumes/:name/backups
```
Compresses (zstd) and uploads. Does not require the volume to be detached, but writes in flight at backup time may produce an inconsistent snapshot — stop the sandbox first if consistency matters.
## Restore from a backup
[Section titled “Restore from a backup”](#restore-from-a-backup)
```http
POST /volumes/:name/backups/restore
```
```bash
curl -X POST http://localhost:8080/volumes/workspace/backups/restore \
-H "Authorization: Bearer $TOKEN" \
-d '{"backup_id": "bk_a1b2c3d4"}'
```
`409` if attached.
## Delete a backup
[Section titled “Delete a backup”](#delete-a-backup)
```http
DELETE /volumes/:name/backups/:backup_id
```
***
# Images
[Section titled “Images”](#images)
## List images
[Section titled “List images”](#list-images)
```http
GET /images
```
Returns user-owned, system tier, and shared images.
## Get an image
[Section titled “Get an image”](#get-an-image)
```http
GET /images/:name
```
## Delete an image
[Section titled “Delete an image”](#delete-an-image)
```http
DELETE /images/:name
```
System tier images can’t be deleted via this endpoint.
## Pull an OCI image
[Section titled “Pull an OCI image”](#pull-an-oci-image)
```http
POST /images/pull
```
Async — returns `202` with a task ID. Poll `/tasks/` until it completes.
```bash
curl -X POST http://localhost:8080/images/pull \
-H "Authorization: Bearer $TOKEN" \
-d '{"ref": "python:3.12", "name": "python-3.12"}'
```
```json
{"task_id": "tsk_abc123", "status": "running"}
```
Optional `auth: "user:token"` for simple HTTP basic auth. For real private registries with rotating credentials, prefer `image import` after a local `docker pull`.
If the same image was already pulled with the same content digest, returns `200` immediately with the existing record.
## Import an image
[Section titled “Import an image”](#import-an-image)
```http
POST /images/import?name=
```
Body is a `docker save`-style tarball streamed as `Content-Type: application/x-tar`. The CLI uses this for `bhatti image import`.
```bash
docker save python:3.12 | curl -X POST \
"http://localhost:8080/images/import?name=python-3.12" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/x-tar" \
--data-binary @-
```
```json
{"name": "python-3.12", "size_mb": 284}
```
## Save a sandbox as an image
[Section titled “Save a sandbox as an image”](#save-a-sandbox-as-an-image)
```http
POST /sandboxes/:id/save-image
```
```bash
curl -X POST http://localhost:8080/sandboxes/dev/save-image \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "my-stack"}'
```
The sandbox keeps running. Only the rootfs is captured; persistent volumes are not part of the image.
***
# Snapshots
[Section titled “Snapshots”](#snapshots)
Named VM snapshots — memory + CPU + disk + processes.
## List snapshots
[Section titled “List snapshots”](#list-snapshots)
```http
GET /snapshots
```
## Get a snapshot
[Section titled “Get a snapshot”](#get-a-snapshot)
```http
GET /snapshots/:name
```
## Delete a snapshot
[Section titled “Delete a snapshot”](#delete-a-snapshot)
```http
DELETE /snapshots/:name
```
## Create a snapshot
[Section titled “Create a snapshot”](#create-a-snapshot)
```http
POST /sandboxes/:id/checkpoint
```
```bash
curl -X POST http://localhost:8080/sandboxes/dev/checkpoint \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "pre-experiment"}'
```
The source sandbox keeps running.
## Resume from a snapshot
[Section titled “Resume from a snapshot”](#resume-from-a-snapshot)
```http
POST /snapshots/:name/resume
```
Creates a **new** sandbox from the snapshot.
```bash
curl -X POST http://localhost:8080/snapshots/pre-experiment/resume \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "dev-restored"}'
```
`name` is optional — auto-generated from the source sandbox name if omitted. Persistent volumes are not re-attached.
***
# Tasks
[Section titled “Tasks”](#tasks)
Async operations get a task ID; poll its status here.
## Get task status
[Section titled “Get task status”](#get-task-status)
```http
GET /tasks/:id
```
```json
{
"id": "tsk_abc123",
"status": "running",
"progress": "downloading layers (45 MB / 142 MB)",
"error": "",
"result": ""
}
```
`status` is `running`, `completed`, or `failed`.
## Cancel a task
[Section titled “Cancel a task”](#cancel-a-task)
```http
DELETE /tasks/:id
```
Best-effort cancel. Some operations are uncancellable past a certain point (e.g. a finalizing image conversion).
***
# Image sharing
[Section titled “Image sharing”](#image-sharing)
There’s no HTTP API for image sharing. It’s a local SQLite operation done with [`bhatti image share --user `](/docs/reference/cli/images/share/) on the server itself, using `--data-dir` to point at the data directory. This is an intentional restriction — image visibility is controlled at the database level, not via the API.
# Configuration
> Server and CLI config files, layered loading, environment variables, and the data directory layout.
bhatti has a single YAML config file. The same format is used by the daemon and the CLI client; the daemon ignores the client-only fields, the client ignores the daemon-only ones.
## Layered loading
[Section titled “Layered loading”](#layered-loading)
The config is loaded from multiple paths and merged. Higher rows override lower rows:
| Source | Loaded? | Used for |
| ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------ |
| `$BHATTI_CONFIG` (path) | If set, **only** this is loaded — no merging. | Override everything; useful for tests and running a second daemon. |
| `/etc/bhatti/config.yaml` | First match wins. | Server settings — engine, runtime paths, listen address, data dir. |
| `~/.bhatti/config.yaml` | Always — fills in only `api_url` and `auth_token` if still empty. | CLI client credentials. |
This means a developer machine that’s also running a server can keep the server config in `/etc/bhatti/config.yaml` (no client credentials) and the CLI’s API key in `~/.bhatti/config.yaml` (without disturbing the server config). Both files are read, neither overwrites the other.
The deprecated location `/var/lib/bhatti/config.yaml` is still honoured as a fallback; the daemon prints a stderr warning when it loads from there. Migrate to `/etc/bhatti/config.yaml`.
## Environment variables
[Section titled “Environment variables”](#environment-variables)
| Variable | Used by | Description |
| --------------------- | ------- | --------------------------------------------------------------------- |
| `BHATTI_CONFIG` | both | Override the config file path. When set, layered loading is bypassed. |
| `BHATTI_LOG_LEVEL` | server | `debug`, `info` (default), `warn`, `error`. |
| `BHATTI_URL` | CLI | API endpoint. |
| `BHATTI_TOKEN` | CLI | API key. |
| `BHATTI_FORCE_STREAM` | CLI | Force NDJSON streaming output even when stdout isn’t a TTY. |
The CLI’s endpoint precedence is: `--flag` → env var (`BHATTI_URL`/`BHATTI_TOKEN`) → config file → the daemon’s local unix socket → built-in default. Env overrides the config file (12-factor: point an already-configured CLI at another daemon without editing `~/.bhatti/config.yaml`). When neither an endpoint flag nor `api_url`/`BHATTI_URL` is set, the CLI talks to the daemon’s local control socket (`/api.sock`), which is not reachable from inside a sandbox.
## Server config
[Section titled “Server config”](#server-config)
bhatti v2 runs the **krucible** engine (libkrun). The daemon itself is pure Go; it spawns a per-sandbox `bhatti-vmm` helper (the VMM) and a per-owner `bhatti-netd` gateway, both shipped in the release bundle under `/runtime/`. A typical server config (the one written by `curl -fsSL bhatti.sh/install | sudo bash`) looks like:
```yaml
engine: krucible
listen: :8080
data_dir: /var/lib/bhatti
# Secure per-owner network gateway (bhatti-netd) is ON by default: the guest is
# isolated from the host, egress is policed, same-owner siblings are reachable.
# Set 'krucible_net_backend: false' for the legacy shared-netstack (TSI) path.
krucible_vmm: /var/lib/bhatti/runtime/bin/bhatti-vmm
krucible_netd: /var/lib/bhatti/runtime/bin/bhatti-netd
krucible_libdir: /var/lib/bhatti/runtime/lib
krucible_kernel_image: /var/lib/bhatti/runtime/kernel/Image-lean-6.12.94-aarch64
krucible_base_image: /var/lib/bhatti/images/rootfs-minimal-arm64.ext4
```
### Top-level fields
[Section titled “Top-level fields”](#top-level-fields)
| Field | Default | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `engine` | `krucible` | Engine backend. v2 is `krucible` (libkrun). (v1 Firecracker lives on the `firecracker` branch.) |
| `listen` | `:8080` | Address for the TCP control API. Empty = unix socket only. |
| `api_socket` | `/api.sock` | Local control-API unix socket. Never reachable from a sandbox. |
| `data_dir` | `~/.bhatti` (CLI) or whatever the install script writes (typically `/var/lib/bhatti` on a server) | Root directory for state — DB, runtime bundle, images, sandboxes, volumes, snapshots. |
| `krucible_vmm` | next to the binary / `PATH` | Path to the `bhatti-vmm` helper (the per-sandbox VMM). |
| `krucible_netd` | next to the binary / `PATH` | Path to the `bhatti-netd` network gateway helper. |
| `krucible_libdir` | autodetect | Directory holding `libkrun` (and, on the non-lean path, `libkrunfw`). The install lays this under `runtime/lib`. |
| `krucible_kernel_image` | autodetect | The lean external guest kernel (`Image-lean-*` on arm64, `vmlinux-lean-*` on x86\_64). \~2× faster cold-start; boots the block-root path. Empty falls back to the bundled libkrunfw kernel. |
| `krucible_base_image` | — | Prebuilt ext4 base rootfs used when `bhatti create` is called without `--image`. Each sandbox gets a copy-on-write overlay over it. |
| `krucible_block_root` | implied when `krucible_base_image` is set | Boot from a CoW block image (required for the cold tier). |
| `krucible_net_backend` | `true` | Per-owner `bhatti-netd` gateway (policed egress, host isolation, sibling reachability). `false` = legacy TSI (guest can reach the host loopback — not recommended). |
| `krucible_socket_dir` | `/tmp/bhatti-kr` | Short directory for vsock/control unix sockets (paths must stay short — `sun_path` caps at 104/108 bytes). |
| `public_proxy_listen` | — | When set (e.g. `:8443`), the daemon exposes a path-based public proxy at this address. Skip this and use [domain mode](#domain-mode) for production. |
| `api_url` | — | CLI-only field. Put it in `~/.bhatti/config.yaml`, not `/etc/bhatti/config.yaml`. |
| `auth_token` | — | CLI-only. Same. |
| `domain` | — | Optional. Enables [domain mode](#domain-mode) — host-based routing + TLS. |
| `backup` | — | Optional. Enables [volume backups](#backup) to S3-compatible storage. |
There is no host TAP/bridge/iptables configuration and no jailer in v2 — krucible owns networking (via `bhatti-netd`/TSI) and boots the VMM as an ordinary host process. (The FC-era `firecracker_*` and `jail_*` fields belong to v1.)
### Domain mode
[Section titled “Domain mode”](#domain-mode)
For host-based routing with TLS — `https://api.` for the API, `https://.` for published sandboxes.
```yaml
domain:
api_host: api.bhatti.sh
proxy_zone: bhatti.sh
tls_cert: /etc/bhatti/wildcard.pem
tls_key: /etc/bhatti/wildcard-key.pem
```
| Field | Description |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api_host` | Hostname for the API. Requests to this host go through normal Bearer auth. |
| `proxy_zone` | Zone for published sandboxes. `.` is the URL `bhatti publish` generates. |
| `tls_cert`, `tls_key` | Paths to a wildcard cert covering `*.` and ``. **Recommended.** |
| `acme_email` | Fallback: per-alias Let’s Encrypt certificates. **Rate-limited** to 50 new aliases per registered domain per week — fine for stable subdomains, fast to hit if you’re stamping out preview environments. |
When domain mode is on, the daemon listens on `:443` (TLS, both API and proxy by Host header), `:80` (ACME challenges + HTTPS redirect), and the local control socket for internal health checks. HTTP/2 is disabled on the TLS listener so WebSocket upgrades work behind a proxy.
You must set either `tls_cert`+`tls_key` or `acme_email`. A wildcard cert is the right answer for any setup that creates more than a handful of aliases per week. See [Custom domain](/docs/managing/custom-domain/) for the full walk-through.
### Backup
[Section titled “Backup”](#backup)
For volume backups to S3-compatible storage. Enables [`bhatti volume backup`](/docs/reference/cli/volumes/backup/), `restore`, `backup-list`, and `backup-delete`. Without this block, those endpoints return `501`.
```yaml
backup:
s3_endpoint: https://s3.eu-central-003.backblazeb2.com
s3_region: eu-central-003
s3_bucket: bhatti-backups
s3_access_key: ...
s3_secret_key: ...
schedule:
- volume: workspace
cron: "0 3 * * *"
retention: 7
```
| Field | Description |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `s3_endpoint` | S3-compatible endpoint URL. Backblaze B2, AWS S3, MinIO, R2, etc. |
| `s3_region` | Region. Required by AWS-compatible APIs even when irrelevant. |
| `s3_bucket` | Bucket name. Must already exist. |
| `s3_access_key`, `s3_secret_key` | Credentials with read/write/delete on the bucket. |
| `schedule` | Optional. Array of automatic backup schedules. Each entry has `volume`, `cron` (5-field cron expression), and `retention` (keep last N backups for that volume). |
Schedules run inside the daemon — no external cron required. Retention is enforced after each scheduled backup.
## CLI client config
[Section titled “CLI client config”](#cli-client-config)
Lives at `~/.bhatti/config.yaml`. `bhatti setup` writes it for you.
```yaml
api_url: https://api.bhatti.sh
auth_token: bht_abc123def456...
```
| Field | Description |
| ------------ | --------------------------------------------- |
| `api_url` | bhatti API endpoint. |
| `auth_token` | The user’s API key from `bhatti user create`. |
## Data directory layout
[Section titled “Data directory layout”](#data-directory-layout)
```plaintext
data_dir/
├── state.db SQLite database (WAL mode)
├── age.key Secret-encryption key (auto-generated on first secret set)
├── id_ed25519, id_ed25519.pub SSH keypair (auto-generated on first start; guest agent identity)
├── api.sock Local control-API unix socket
├── .latest-version Cache for `bhatti version`'s GitHub-release check (~/.bhatti only)
├── runtime/ Relocatable v2 runtime bundle (re-laid on every install/update)
│ ├── bin/ bhatti-vmm, bhatti-netd
│ ├── lib/ libkrun (the krucible VMM shared lib) + symlinks
│ └── kernel/ Image-lean-- (arm64) / vmlinux-lean-- (x86_64)
├── images/
│ └── rootfs--.ext4 Base rootfs images (CoW-overlaid per sandbox)
├── sandboxes//
│ ├── qcow2 overlay over the base image (the sandbox's disk deltas)
│ ├── config drive env, secrets, files, per-sandbox token (~1 MB ext4, /dev/vdb)
│ ├── state.json recovery metadata (helper PID, sockets, bundle ref)
│ └── (when cold) memory image + VM state + attached volumes + manifest.json
├── volumes// Standalone persistent volumes
└── snapshots/// Named-snapshot bundles (self-contained)
```
`` is `arm64` on aarch64 hosts, `amd64` on x86\_64. There are no `jails/` or `firecracker.sock` entries in v2 — those are v1/Firecracker.
## Validating a config
[Section titled “Validating a config”](#validating-a-config)
The daemon prints which file it loaded on startup:
```plaintext
config loaded path=/etc/bhatti/config.yaml
```
If the path is empty, no config file was found and built-in defaults are used. The CLI client doesn’t print this; check with `bhatti version` (which reports the loaded endpoint).
# Documentation
> Redirecting to Quickstart...
# Adding a Tier
> How to create a new rootfs tier for bhatti.
Tiers are pre-built ext4 root filesystem images. Each tier builds on `minimal` and adds specific tooling. The server auto-discovers tiers at startup by globbing `rootfs-*-{arch}.ext4` in the images directory.
## Steps
[Section titled “Steps”](#steps)
### 1. Create the tier script
[Section titled “1. Create the tier script”](#1-create-the-tier-script)
Add `scripts/tiers/.sh`. This runs inside a chroot during the build. It receives `$MOUNT`, `$ARCH`, `$DEB_ARCH`, `$AGENT`, and `$SCRIPT_DIR` as environment variables.
Most tiers source minimal first:
```bash
#!/bin/bash
set -euo pipefail
"$SCRIPT_DIR/tiers/minimal.sh"
chroot "$MOUNT" /bin/bash -c '
apt-get update -qq
apt-get install -y --no-install-recommends your-packages
apt-get clean
rm -rf /var/lib/apt/lists/*
'
```
### 2. Long-running daemons: ship them as systemd units
[Section titled “2. Long-running daemons: ship them as systemd units”](#2-long-running-daemons-ship-them-as-systemd-units)
If your tier needs a daemon running on boot — a database, a headless browser, a VNC gateway — ship it as a `.service` file and let lohar’s [`systemctl` shim](/docs/under-the-hood/lohar-the-blacksmith/) manage it. Don’t write `init.sh` (the legacy pattern); don’t fork the daemon yourself; don’t try to manage PIDs, restarts, or cgroups.
The canonical short example is `headless-chrome.service` from the browser tier:
```ini
[Unit]
Description=Headless Chromium with Chrome DevTools Protocol
After=network.target
[Service]
Type=simple
# Defaults; user-supplied values in /run/bhatti/config-env override.
Environment=CHROME_REMOTE_PORT=9222
Environment=CHROME_FLAGS=
EnvironmentFile=-/run/bhatti/config-env
ExecStart=/usr/local/bin/headless_shell --no-sandbox --disable-gpu \
--disable-dev-shm-usage \
--remote-debugging-port=${CHROME_REMOTE_PORT} \
--remote-debugging-address=0.0.0.0 \
$CHROME_FLAGS
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
```
The pattern:
* **`Type=simple`** for daemons that stay in the foreground (the common case). **`Type=notify`** for daemons that speak `sd_notify(3)` and signal `READY=1` once their socket is up (`dockerd`, `postgresql`). **`Type=oneshot`** with `RemainAfterExit=yes` for first-boot setup helpers (password generation, env-file writers).
* **`ExecStart=`** points at the binary directly. No `/bin/sh -c` wrapper. The shim already wraps your command in `/bin/sh -c "exec "` for variable expansion; nesting another shell is redundant at best.
* **`Restart=on-failure`** + **`RestartSec=2s`** is the right default for long-running daemons. The shim respects the standard `StartLimitBurst` / `StartLimitIntervalSec` so a crash loop gives up cleanly.
* **User-tunable knobs**: declare defaults via `Environment=` and add `EnvironmentFile=-/run/bhatti/config-env`. The leading `-` makes the file optional, so sandboxes with no `--env` flags still boot cleanly. Values passed at create time (`bhatti create --env CHROME_REMOTE_PORT=9333`) override the unit’s defaults via the config-env bridge that lohar materialises at boot ([`cmd/lohar/main.go`](https://github.com/sahil-shubham/bhatti/blob/main/cmd/lohar/main.go)).
Drop the unit into `/etc/systemd/system/.service` and symlink it into `multi-user.target.wants/` so lohar’s `startEnabledServices` picks it up at boot:
```bash
cat > "$MOUNT/etc/systemd/system/headless-chrome.service" << UNIT
... (unit body above) ...
UNIT
ln -sf /etc/systemd/system/headless-chrome.service \
"$MOUNT/etc/systemd/system/multi-user.target.wants/headless-chrome.service"
```
That’s all. The shim handles cgroup placement (race-free, see [How services are spawned](/docs/under-the-hood/lohar-the-blacksmith/#how-services-are-spawned)), pidfile creation, restart-on-failure, and `journalctl -u` log routing. Your unit just declares what to run.
#### Anti-patterns
[Section titled “Anti-patterns”](#anti-patterns)
Three mistakes will silently break your tier:
* **Don’t write `init.sh`.** The legacy pre-v1.11.3 pattern (one shell script in `/etc/bhatti/init.sh` that runs all your daemons in the background) is gone. Daemons started that way escape the shim’s lifecycle entirely — no `systemctl status`, no `journalctl -u`, no `Restart=`, no `cgroup.kill` on stop. Existing tiers were converted off `init.sh` in v1.11.7–v1.11.9.
* **Don’t wrap `ExecStart=` in `/bin/sh -c '...'` yourself.** The shim does it for you. An inner shell is redundant on a good day; on a bad day (a daemon that double-`fork`s during startup) it introduces a fork the placement helper has to chase. Use `Environment=` and `${VAR}` expansion in the unit file — systemd’s parser handles that natively.
* **Don’t manage cgroups, pidfiles, or restart loops yourself.** All three live in the shim once your unit is enabled. Writing your own `pkill` cleanup or pidfile parsing means you’re fighting the shim, not using it.
You don’t need to know about `lohar spawn` to author a unit. The helper sits upstream of `ExecStart=` and is invisible to the unit file — it’s how the shim gets the daemon into the right cgroup before any fork. If you find yourself reading [`cmd/lohar/spawn.go`](https://github.com/sahil-shubham/bhatti/blob/main/cmd/lohar/spawn.go) you’ve probably gone too deep.
### 3. Add size default in `scripts/build-tier.sh`
[Section titled “3. Add size default in scripts/build-tier.sh”](#3-add-size-default-in-scriptsbuild-tiersh)
```bash
case "$TIER" in
minimal) SIZE_MB="${SIZE_MB:-512}" ;;
browser) SIZE_MB="${SIZE_MB:-2048}" ;;
new-tier) SIZE_MB="${SIZE_MB:-1024}" ;; # ← add this
*) echo "unknown tier: $TIER" >&2; exit 1 ;;
esac
```
### 4. Add to CI release matrix
[Section titled “4. Add to CI release matrix”](#4-add-to-ci-release-matrix)
In `.github/workflows/release.yml`:
```yaml
tier: [minimal, browser, docker, computer, new-tier]
```
### 5. Add to the install script
[Section titled “5. Add to the install script”](#5-add-to-the-install-script)
In `scripts/install.sh`, update the interactive tier prompt and the `ALL_KNOWN_TIERS` variable in `do_server_update()`.
### 6. Verify
[Section titled “6. Verify”](#6-verify)
The bats test suite (`scripts/install_test.bats`) validates that every tier in `scripts/tiers/` appears in all four places:
```bash
bats scripts/install_test.bats
```
If you miss a registration point, the tier consistency tests catch it.
## Checklist
[Section titled “Checklist”](#checklist)
```plaintext
[ ] scripts/tiers/.sh
[ ] scripts/tiers/.sh — .service unit(s) for long-running daemons,
symlinked into multi-user.target.wants/
[ ] scripts/build-tier.sh — SIZE_MB case
[ ] .github/workflows/release.yml — matrix.tier
[ ] scripts/install.sh — interactive menu
[ ] scripts/install.sh — ALL_KNOWN_TIERS
[ ] bats tests pass
```
# Kernel
> The lean external guest kernel — how it's configured, built, and loaded on the krucible boot path.
bhatti v2 boots guests on a custom-built **lean Linux kernel** — a minimal 6.12.94 with only the features a sandbox workload needs. It’s an *external* kernel: krucible loads it directly with `krun_set_kernel`, which bypasses the kernel bundled inside `libkrunfw`. Skipping libkrunfw’s driver/subsystem init is worth roughly **2× on cold-start** — boot→agent lands at \~312ms on HVF versus \~610ms with the stock bundled kernel.
## Building
[Section titled “Building”](#building)
The kernel is built reproducibly inside Docker so the macOS dev box and Linux CI produce the same binary:
```bash
# Build for the host architecture (default), output to dist/kernel/
scripts/build-lean-kernel.sh
# Or name the target arch explicitly
scripts/build-lean-kernel.sh aarch64
scripts/build-lean-kernel.sh x86_64
```
The script pulls the pinned kernel.org source (`KERNEL_VERSION`, default `6.12.94`), copies our in-repo config over it, runs `make olddefconfig`, and builds the image:
* **arm64** → `arch/arm64/boot/Image` → `dist/kernel/Image-lean--`
* **x86\_64** → `vmlinux` → `dist/kernel/vmlinux-lean--`
Override the version with `KERNEL_VERSION=` (must be a `linux-stable` tag). There’s no libkrunfw in this path — the external kernel replaces it entirely.
## How it’s loaded
[Section titled “How it’s loaded”](#how-its-loaded)
The daemon **autodetects** the lean kernel: if a `dist/kernel/*-lean-*` image exists it’s used automatically; if not, krucible falls back to the `libkrunfw` bundle (slower, but keeps things working before you’ve built one). You can also point the daemon at a specific image with the `krucible_kernel_image` config field. `bhatti-vmm` passes the image to `krun_set_kernel` along with an arch-aware kernel command line, and `krun_start_enter` then skips libkrunfw entirely.
## Key config choices
[Section titled “Key config choices”](#key-config-choices)
The config under `scripts/lean-kernel/` (`config-lean_aarch64`, `config-lean_x86_64`) is deliberately lean — \~998 `=y` symbols versus \~1433 in the libkrunfw-equivalent config. Fewer subsystems to initialize means a faster boot and a smaller footprint. It enables exactly what block-root microVM guests use:
* **virtio** — virtio-blk (root overlay, config drive, volumes), virtio-net (the netd link), virtio-vsock (host↔guest control), virtio-console, virtio-mmio, balloon
* **ext4** — filesystem for the root image, config drive, and volumes; plus **overlayfs** and **virtio-fs**
* **devtmpfs** — required for `/dev` device nodes (auto-mounted)
* **TTY/PTY** — for interactive shell sessions
* **block layer** — the loop device and block-integrity bits the CoW image stack relies on
Everything unnecessary for a sandbox is turned off: USB, sound, most filesystems, most network drivers, 9p, initrd/initramfs support (guests are block-root, so there’s no ramdisk boot), and hardware-specific drivers.
## Architecture support
[Section titled “Architecture support”](#architecture-support)
Two configs are maintained, one per target arch:
* `config-lean_aarch64` — Raspberry Pi 5, Graviton, Apple Silicon, and other arm64 hosts (produces `Image-lean-*`)
* `config-lean_x86_64` — x86\_64 bare metal and cloud instances (produces `vmlinux-lean-*`)
The build script selects the source config and the make target from the arch argument, and the daemon selects the matching image for the host architecture at runtime.
# Testing
> Test philosophy, categories, and how to run bhatti's ~25,000 lines of tests.
\~25,000 lines of tests across \~95 test files. Zero mocks for VM tests — all krucible integration tests run on real microVMs, on real KVM (Linux) or HVF (Apple Silicon).
## Philosophy
[Section titled “Philosophy”](#philosophy)
**Test at the real boundary.** The engine tests drive a real krucible VM — a real `bhatti-vmm`, a real lohar guest — not a mock. Mocks silently accept any input and hide broken behavior; a real boot doesn’t.
**Test the protocol without VMs.** lohar has a test mode (`runTestMode`) that listens on Unix sockets instead of vsock. The whole protocol/handler surface runs on macOS or Linux in seconds, without root or a hypervisor.
**Test performance against budgets.** Timing-sensitive paths (cold restore, warm resume, boot→agent) assert on a measured latency budget, not a comment — so a regression that doubles cold-restore time fails a test instead of quietly shipping.
## Running tests
[Section titled “Running tests”](#running-tests)
### Protocol and agent tests (macOS/Linux, no root)
[Section titled “Protocol and agent tests (macOS/Linux, no root)”](#protocol-and-agent-tests-macoslinux-no-root)
```bash
go test ./pkg/agent/proto/ # frame encoding, round-trips
go test ./cmd/lohar/ # agent handlers via Unix socket (test mode)
go test ./pkg/agent/ # host-side client
```
These need no hypervisor — they exercise lohar over Unix sockets and the wire protocol directly.
### krucible engine tests (real VMs)
[Section titled “krucible engine tests (real VMs)”](#krucible-engine-tests-real-vms)
The full VM suite is behind the `krucible` build tag and needs a hypervisor (KVM or HVF):
```bash
go test -tags krucible ./pkg/engine/krucible/ -count=1
```
On macOS (Apple Silicon), build the fork and the VMM helper first, then run the suite locally on HVF:
```bash
make krucible # build libkrun (our fork) + assemble the link prefix
make vmm # build & sign bhatti-vmm (cgo against libkrun)
go test -tags krucible ./pkg/engine/krucible/ -count=1
```
Build the lean guest kernel too (`scripts/build-lean-kernel.sh`) for the faster external-kernel boot path — the daemon autodetects it. If a hypervisor isn’t available, the VM suites self-skip via `hasHypervisor()`.
### Cross-engine behavior
[Section titled “Cross-engine behavior”](#cross-engine-behavior)
VMM-agnostic behavior assertions live in `pkg/engine/enginetest` — the core an engine must satisfy (status/list, exec exit codes and stdout, the file API). krucible’s tests wire these suites to a real engine, so the same behavior contract is checked against the running VMM rather than bespoke per-engine scripts.
## Continuous integration
[Section titled “Continuous integration”](#continuous-integration)
Two workflows cover the engine:
* **`ci.yml` → `krucible-build`** (GitHub-hosted, no `/dev/kvm`). Builds the Rust fork (`make krucible`), the cgo helper (`make vmm`), and the `-tags krucible` Go, then runs the pure-unit tests (bundle, capability, recovery, state). The VM suites self-skip here — there’s no hypervisor on a hosted runner — so this is the build/link + unit gate.
* **`krucible-integration.yml`** (self-hosted `arc-runner-set`, real KVM). Brings up libkrun + `bhatti-vmm` + `bhatti-netd`, builds lohar, boots a real release-tier rootfs, and runs the full `-tags krucible` suite on actual hardware (arm64 and x86). This is the canonical parity gate for v2, and it touches no host networking — netd/TSI keep everything in userspace.
## Test categories
[Section titled “Test categories”](#test-categories)
| Category | Location | Requires |
| ------------- | ------------------------------------- | ------------------------------- |
| Protocol | `pkg/agent/proto/` | Nothing |
| Agent (guest) | `cmd/lohar/` | Nothing (Unix-socket test mode) |
| Client | `pkg/agent/` | Nothing |
| Cross-engine | `pkg/engine/enginetest/` | Hypervisor (via krucible) |
| Engine | `pkg/engine/krucible/` | libkrun + KVM/HVF |
| Server | `pkg/engine/krucible/` | libkrun + KVM/HVF |
| CLI | `cmd/bhatti/` | libkrun + KVM/HVF |
| Recovery | `cmd/bhatti/`, `pkg/engine/krucible/` | Mixed (unit vs VM) |
### What each category covers
[Section titled “What each category covers”](#what-each-category-covers)
* **Protocol tests** — binary framing: round-trips, EOF handling, max frame size, concurrent writes
* **Agent tests** — exec (exit codes, env vars, kill, process groups), TTY sessions (detach/reattach, scrollback, resize), files (read/write/stat/ls, truncation, atomicity), and the PID-1 lohar shims (systemctl/journalctl over the in-guest Unix socket)
* **Client tests** — the host-side agent client against lohar in test mode
* **Cross-engine tests** — the VMM-agnostic behavior contract every engine must satisfy
* **Engine tests** — full VM lifecycle on a real krucible boot: create/exec/destroy, full snapshot/restore, fork (CoW memory clone), cold-tier restore, qcow2 overlays and mounts, volumes, and networking through `bhatti-netd` (egress policing, same-owner sibling reachability, vsock forward/tunnel)
* **Server tests** — the full daemon stack (HTTP API + store + thermal manager) over a real engine, including the wake-on-request path (exec against a cold sandbox transparently cold-restores it)
* **CLI tests** — end-to-end: create → exec → file → shell → destroy
* **Recovery tests** — daemon restart scenarios: adopting live helpers by PID/cmdline, crashed VMs, missing snapshots, orphaned state
# Custom Domain
> Set up a domain so bhatti publish creates real URLs like alias.yourdomain.com.
By default, `bhatti publish` generates URLs using the server’s IP or hostname. With a custom domain, published sandboxes get proper URLs like `my-app.yourdomain.com` with TLS.
## What you need
[Section titled “What you need”](#what-you-need)
* A domain (e.g. `yourdomain.com`)
* DNS access to create A and wildcard records
* Either a wildcard TLS certificate or an email for Let’s Encrypt
## DNS records
[Section titled “DNS records”](#dns-records)
Point your API hostname and a wildcard for published sandboxes to your server:
```plaintext
A api.yourdomain.com →
A *.yourdomain.com →
```
The wildcard record is what makes `my-app.yourdomain.com` work — every published alias becomes a subdomain.
## Server configuration
[Section titled “Server configuration”](#server-configuration)
Add the `domain` block to `/etc/bhatti/config.yaml`:
### Option A: Wildcard certificate (recommended)
[Section titled “Option A: Wildcard certificate (recommended)”](#option-a-wildcard-certificate-recommended)
If you have a wildcard cert (from your DNS provider, Cloudflare Origin CA, or `certbot --manual` with DNS challenge):
```yaml
domain:
api_host: api.yourdomain.com
proxy_zone: yourdomain.com
tls_cert: /etc/bhatti/wildcard.pem
tls_key: /etc/bhatti/wildcard-key.pem
```
### Option B: Let’s Encrypt (automatic per-alias certs)
[Section titled “Option B: Let’s Encrypt (automatic per-alias certs)”](#option-b-lets-encrypt-automatic-per-alias-certs)
If you don’t have a wildcard cert, bhatti can issue individual certificates via Let’s Encrypt:
```yaml
domain:
api_host: api.yourdomain.com
proxy_zone: yourdomain.com
acme_email: you@example.com
```
Caution
Let’s Encrypt has a rate limit of 50 new certificates per week per registered domain. If you create and publish many sandboxes rapidly (e.g. per-PR previews in CI), you’ll hit this limit. Use a wildcard cert for production preview environments.
## What changes in domain mode
[Section titled “What changes in domain mode”](#what-changes-in-domain-mode)
Without `domain` config, bhatti listens on `:8080` for everything.
With `domain` config, bhatti starts three listeners:
| Port | Purpose |
| ---------------- | ---------------------------------------------------------------- |
| `:443` | API (`api.yourdomain.com`) + proxy (`*.yourdomain.com`) with TLS |
| `:80` | ACME challenges + HTTPS redirect |
| `127.0.0.1:8080` | Internal API (localhost only — health checks, admin) |
Routing is by `Host` header: requests to `api.yourdomain.com` go through normal API auth. Requests to `anything-else.yourdomain.com` go to the public proxy (no auth — this is the published URL).
## Restart
[Section titled “Restart”](#restart)
```bash
sudo systemctl restart bhatti
```
Check the logs for:
```plaintext
bhatti listening (domain mode)
api: https://api.yourdomain.com
proxy: https://*.yourdomain.com
```
## Verify
[Section titled “Verify”](#verify)
```bash
bhatti create --name test
bhatti publish test -p 8080 -a hello
# → https://hello.yourdomain.com
```
## Updating CLI config
[Section titled “Updating CLI config”](#updating-cli-config)
Remote CLI users should update their endpoint:
```bash
bhatti setup
# API endpoint: https://api.yourdomain.com
# API key: ****
```
## Getting a wildcard certificate
[Section titled “Getting a wildcard certificate”](#getting-a-wildcard-certificate)
### Cloudflare (if your DNS is on Cloudflare)
[Section titled “Cloudflare (if your DNS is on Cloudflare)”](#cloudflare-if-your-dns-is-on-cloudflare)
Origin CA certificates are free and last 15 years. Create one in the Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate. Select `*.yourdomain.com` and `yourdomain.com`. Download the PEM files.
### certbot with DNS challenge
[Section titled “certbot with DNS challenge”](#certbot-with-dns-challenge)
```bash
sudo certbot certonly --manual --preferred-challenges dns \
-d "*.yourdomain.com" -d "yourdomain.com"
```
This asks you to create a DNS TXT record to prove domain ownership. After validation, certs are saved to `/etc/letsencrypt/live/yourdomain.com/`. Renew every 90 days.
### acme.sh (lightweight alternative)
[Section titled “acme.sh (lightweight alternative)”](#acmesh-lightweight-alternative)
```bash
acme.sh --issue -d "*.yourdomain.com" -d "yourdomain.com" --dns dns_cf
```
Supports automatic DNS API integration with most providers. See [acme.sh DNS API](https://github.com/acmesh-official/acme.sh/wiki/dnsapi).
# Images & Tiers
> Built-in rootfs tiers, custom-image workflows, and how image scoping and sharing work.
Sandbox root filesystems are ext4 images. bhatti ships pre-built Ubuntu 24.04 *tier* images out of the box and supports three ways to add your own: pull from a public OCI registry, import a Docker image, or save a configured sandbox as an image.
The full CLI surface is in [Images reference](/docs/reference/cli/images/). This page covers the tier landscape, the typical workflows, and how scoping/sharing work — none of which is in the per-command pages.
## Built-in tiers
[Section titled “Built-in tiers”](#built-in-tiers)
bhatti ships four built-in tiers. Each is a pre-built Ubuntu 24.04 rootfs that builds on top of `minimal`:
| Tier | Adds | Size |
| -------------------------------------------- | -------------------------------------- | -------: |
| [`minimal`](/docs/managing/tiers/) | (base) | \~200 MB |
| [`browser`](/docs/managing/tiers/browser/) | Chromium, Playwright, Node 22 | \~600 MB |
| [`docker`](/docs/managing/tiers/docker/) | Docker Engine, buildx, compose, binfmt | \~550 MB |
| [`computer`](/docs/managing/tiers/computer/) | XFCE, KasmVNC, Chromium, full desktop | \~1.5 GB |
```bash
bhatti create --name scraper --image browser
bhatti create --name ci --image docker
```
The server install prompts for one tier on first run; install more later with `sudo bhatti update --tiers all` (or a comma-separated list). Tiers are auto-discovered from `/images/rootfs--.ext4` — no hardcoded list.
The deep dive for each tier — how its long-running daemons are managed, what env knobs you can pass, sizing, troubleshooting — lives at [Tiers](/docs/managing/tiers/). The rest of this page is about images you build *on top* of those tiers.
## Building custom images
[Section titled “Building custom images”](#building-custom-images)
Three workflows depending on where the source lives.
### Pull from a public registry
[Section titled “Pull from a public registry”](#pull-from-a-public-registry)
```bash
bhatti image pull python:3.12
bhatti image pull node:22-slim --name node-22
```
Async, server-side. The CLI shows progress and exits when the image is ready.
### Import from local Docker
[Section titled “Import from local Docker”](#import-from-local-docker)
```bash
docker pull ghcr.io/myorg/private:latest # any auth Docker can do
bhatti image import ghcr.io/myorg/private:latest
```
The CLI runs `docker save` locally and streams the tar to the server. This is the recommended path for private registries — Docker handles your existing auth, bhatti just receives bytes.
For tarballs without Docker:
```bash
docker save ubuntu:24.04 > /tmp/ubuntu.tar
bhatti image import --tar /tmp/ubuntu.tar --name ubuntu-24
```
### Save a configured sandbox
[Section titled “Save a configured sandbox”](#save-a-configured-sandbox)
```bash
bhatti create --name build --image minimal
bhatti exec build -- apt-get update && apt-get install -y nodejs pnpm
bhatti exec build -- pnpm install -g some-tool
bhatti image save build --name node-stack
```
The captured image now stamps out new sandboxes with everything pre-installed:
```bash
bhatti create --name worker-1 --image node-stack
bhatti create --name worker-2 --image node-stack
```
Only the **rootfs** is captured — persistent volumes are not part of the image. If your stack puts work in a volume, snapshot the volume separately ([`bhatti volume clone`](/docs/reference/cli/volumes/clone/)) and re-attach when stamping.
## Disk usage
[Section titled “Disk usage”](#disk-usage)
A common question when you first look at `/var/lib/bhatti/`: *“how much disk does each sandbox actually cost?”* The short answer in v2 is **the bytes the sandbox has written since boot** — and it’s the same answer on any filesystem, because copy-on-write lives in the disk-image format, not in the filesystem.
A fresh sandbox dir looks like this:
```plaintext
/var/lib/bhatti/
images/
rootfs-computer-arm64.ext4 1.5G (logical) read-only base image
rootfs-minimal-arm64.ext4 ~200M
sandboxes//
root.qcow2 (qcow2 CoW overlay over the base — the sandbox's write deltas)
config.ext4 ~1M (logical) env, secrets, file drops
bundle/ (only while cold: memory image + VM state + overlay copy + volumes)
```
Each sandbox’s `root.qcow2` is a thin overlay *over* one of the base images — a metadata-only header at create time that only allocates blocks as the guest writes them. `ls -lh` shows the *logical* device size the guest sees; `du -h` shows what’s actually on disk. The gap is usually enormous, and it’s the whole reason many sandboxes off one base are affordable:
```bash
$ du -sh /var/lib/bhatti/sandboxes/abc123/
45M /var/lib/bhatti/sandboxes/abc123/
$ ls -lh /var/lib/bhatti/sandboxes/abc123/root.qcow2
-rw------- 1 root root 1.0G ... # logical device size, not real allocation
```
### The per-sandbox cost
[Section titled “The per-sandbox cost”](#the-per-sandbox-cost)
The marginal cost of adding a sandbox is the bytes it writes over the shared base — config tweaks, log lines, packages installed since boot — typically single-digit MiB until you do something heavy. The base image is stored once and shared by every sandbox’s overlay; the OS page cache likewise holds one copy of the read-only base pages in RAM across all of them. Overhead of the qcow2 indirection is \~0.5%.
This is **filesystem-independent**. ext4, xfs, btrfs, and APFS all get instant create-from-base and shared-base dedup — there’s no ext4 penalty and no btrfs requirement (that was v1). If you *want* to shrink the data dir further, btrfs and xfs can transparently compress the base images and cold bundles, but that’s an optional optimization, not a prerequisite. See [Self-hosting → Storage, no special filesystem needed](/docs/self-hosting/#storage--no-special-filesystem-needed) and [Storage](/docs/under-the-hood/storage/) for the full picture, including the `KRUCIBLE_ROOT_RAW=1` opt-out for a plain raw ext4 root.
## Scoping and sharing
[Section titled “Scoping and sharing”](#scoping-and-sharing)
Images you create are private to your user — other users can’t see them in `image list`, can’t reference them by name, can’t read the underlying file.
To share an image with a specific user (or list of users):
```bash
sudo bhatti image share my-image --user alice --user bob
```
This command operates directly on the local SQLite database, so it requires running on the server with DB access (`sudo`). It is not an HTTP API call. There’s no “share with everyone” mode — sharing is always with named users. Inspect current shares with `--list`; revoke with [`bhatti image unshare`](/docs/reference/cli/images/unshare/).
System tier images (`minimal`, `browser`, …) are visible to everyone on the server automatically; they live under `user_id=""` in the database.
## See also
[Section titled “See also”](#see-also)
* [Tiers](/docs/managing/tiers/) — the four built-in starting points, with per-tier deep dives
* [Images reference](/docs/reference/cli/images/) — every command
* [Adding a tier](/docs/contributing/adding-a-tier/) — build a new system tier
# Secrets
> How encryption, scoping, and env-var injection work for sandbox secrets.
Secrets are values you’d rather not put on a command line. bhatti stores them encrypted, scoped per user, and injects them as environment variables when a sandbox boots.
The CLI surface lives in [Secrets reference](/docs/reference/cli/secrets/). This page covers the *model* — what’s encrypted, what isn’t, and the rules for which env var wins when multiple sources set the same name.
## Encryption at rest
[Section titled “Encryption at rest”](#encryption-at-rest)
On first start the daemon generates an [age](https://age-encryption.org/) keypair at `/age.key`. Every secret you set is encrypted under this key. Plaintext is never written to disk on the server — not in the database, not in temp files.
The age key file is the only thing that decrypts your secrets. Back it up alongside the database; lose either and the secrets become unrecoverable.
## Scoping
[Section titled “Scoping”](#scoping)
Secrets are per-user. Other users can’t list, read, or reference them by name. There’s no shared / global secret store.
`secret list` returns names + timestamps but never the encrypted blob — once a value is set, you can replace it with `secret set NAME new-value` but you can’t read it back.
## How injection works
[Section titled “How injection works”](#how-injection-works)
When a sandbox is created with `--secret API_KEY`:
1. The server fetches the encrypted blob from the secrets table for that user.
2. It decrypts under the age key.
3. The plaintext is written into the sandbox’s config drive as part of the env-var bundle.
4. lohar reads the config drive at boot and exports `API_KEY=` to every command run inside the sandbox.
The config drive is unmounted after boot so the value isn’t readable from the running rootfs without lohar’s help.
## What happens on wake
[Section titled “What happens on wake”](#what-happens-on-wake)
Nothing secret-related. Decryption happens exactly once, at sandbox *create*, when the daemon reads the ciphertext from SQLite, decrypts it with the age key on disk, and writes the plaintext into the config drive. From that moment on, the secret lives as bytes in guest RAM and is captured into the memory image (`memory.img`) on snapshot.
When a [cold sandbox wakes](/docs/under-the-hood/thermal-states/#cold--hot):
* The server does **not** re-read from SQLite.
* It does **not** load the age key.
* It does **not** rewrite the config drive.
* It restores the memory image into a fresh krucible VM and resumes. The env vars are already in the guest’s process memory.
The age key (`/age.key`) is only loaded on an explicit encrypt or decrypt call — `bhatti secret set` or `bhatti create --secret`. It is **not** kept in long-lived daemon memory.
This matters for latency: a cold wake doesn’t pay any secret-reconstruction cost. The 360 ms p50 number on the homepage is dominated by the memory-image disk read, not by anything secret-handling — see [Storage → Cold wake and the page cache](/docs/under-the-hood/storage/#cold-wake-and-the-page-cache).
### Threat model
[Section titled “Threat model”](#threat-model)
* **Stolen `state.db`**: safe (ciphertext only, no key).
* **Cross-tenant access**: safe (per-user scoping + auth).
* **Stolen `state.db` + `age.key` together**: not safe; both files live in `` and an attacker with filesystem access has everything.
* **Host root compromise**: not safe.
* **Memory-image exfiltration**: not safe; secrets are env vars in guest RAM and the memory image (`memory.img`) is unencrypted on disk.
The host root is the trust boundary, same as Vault-on-a-box. If you need to weaken that — wrap `age.key` in KMS/HSM. We do not currently support that; it’s an open design question.
## Env var priority inside a sandbox
[Section titled “Env var priority inside a sandbox”](#env-var-priority-inside-a-sandbox)
When a command runs, env vars resolve in this order — later wins:
```plaintext
1. defaults (PATH, HOME, TERM, LANG)
↓
2. secrets (--secret NAME at create time)
↓
3. sandbox env (--env K=V at create time)
```
There is **no** per-`bhatti exec` env override. The `POST /sandboxes/:id/exec` endpoint doesn’t accept an `env` field — anything passed there is silently ignored. To inject env vars at exec time, use the WebSocket exec path (which does take an `env` map on the initial JSON command spec). For most use cases, set them at create time and accept that they apply to every command.
## Caveat: `--template` ignores `--secret`
[Section titled “Caveat: --template ignores --secret”](#caveat---template-ignores---secret)
Creating a sandbox from a template silently drops request-side `--secret` and `--file` flags. Only the secrets pre-configured on the template are honoured. If you need request-time secrets, create directly without `--template`.
## See also
[Section titled “See also”](#see-also)
* [Secrets reference](/docs/reference/cli/secrets/) — `set`, `list`, `delete`
* [`bhatti create --secret`](/docs/reference/cli/sandbox/create/) — inject at sandbox boot
# Tiers
> Built-in rootfs tiers, the per-tier operational story, and which one to pick.
A *tier* is a pre-built Ubuntu 24.04 rootfs that ships as a starting point for sandboxes. bhatti has four. Each builds on `minimal` and adds tooling for a specific workload.
```bash
bhatti create --name dev --image docker
bhatti create --name scraper --image browser
bhatti create --name desk --image computer --cpus 2 --memory 4096
```
Pick the one closest to what you’re doing — every tier is a real Ubuntu, so you can always `apt-get install` more on top.
## The four tiers
[Section titled “The four tiers”](#the-four-tiers)
| Tier | Adds on top of `minimal` | Approx size | Typical use |
| ------------------------- | -------------------------------------- | ----------: | ---------------------------------------- |
| [`minimal`](#minimal) | (nothing — bare base) | \~200 MB | Custom builds, anything CLI-first |
| [`browser`](./browser/) | Chromium, Playwright, Node 22 | \~600 MB | Scraping, CDP automation, headless tests |
| [`docker`](./docker/) | Docker Engine, buildx, compose, binfmt | \~550 MB | Docker-in-VM, OCI builds, **multi-arch** |
| [`computer`](./computer/) | XFCE, KasmVNC, Chromium, full desktop | \~1.5 GB | Visual / agent-driven desktop sessions |
The server install prompts for one tier on first run. Install more later:
```bash
sudo bhatti update --tiers all
# or pick: --tiers docker,browser
```
The server auto-discovers any `rootfs--.ext4` it finds in its images directory, so installing a tier is just a download — no config changes, no daemon restart logic to think about.
## Units shipped by each tier
[Section titled “Units shipped by each tier”](#units-shipped-by-each-tier)
What’s actually running inside each tier, post-boot. Every unit listed here is managed by lohar’s [`systemctl` shim](/docs/under-the-hood/lohar-the-blacksmith/) — no real systemd anywhere — and the same three commands (`systemctl status`, `journalctl -u`, `systemctl restart`) work the same way regardless of tier.
| Tier | Unit | `Type=` | What it does |
| ---------- | ---------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal` | (none shipped) | — | Provides the shim infrastructure. Anything you `apt install` (openssh-server, postgresql, nginx) becomes shim-managed on first boot. |
| `browser` | `headless-chrome.service` | `simple` | `headless_shell` with `--remote-debugging-port=9222`. Long-running, no PIDFile, restarted on crash. |
| `docker` | `docker.service` | `notify` | `dockerd` from upstream `docker-ce`. Drop-in binds the socket directly (no `fd://` socket activation) and chmods it for the `lohar` user. |
| `computer` | `kasmvnc-firstboot.service` | `oneshot` | Generates a per-sandbox VNC password on first boot. `RemainAfterExit=yes` so dependent units’ `After=` clauses are satisfied. |
| `computer` | `kasmvnc.service` | `simple` | `Xkasmvnc` — X server + RFB→WebSocket gateway, the endpoint on `:6080`. After `kasmvnc-firstboot.service`. |
| `computer` | `xfce-session.service` | `simple` | XFCE desktop session. `Requires=kasmvnc.service` so killing kasmvnc cascades. |
| `computer` | `bhatti-display-env.service` | `oneshot` | Writes `DISPLAY=:99` into the env file `bhatti exec` reads, so `screenshot` and `xdotool` Just Work without per-call env. |
If a tier’s daemon crashes, the shim’s `Restart=on-failure` policy brings it back; you see the crash and the restart in `journalctl -u `. If you edit a config file, `systemctl restart ` picks it up. Every unit in this table is `Restart=on-failure` (long-running daemons) or `oneshot` (firstboot helpers). The cgroup-placement mechanism that makes `systemctl stop` reliable for forking daemons like `Xkasmvnc` is documented at [How services are spawned](/docs/under-the-hood/lohar-the-blacksmith/#how-services-are-spawned).
## The common operator story
[Section titled “The common operator story”](#the-common-operator-story)
Every long-running process in every built-in tier is managed by lohar’s **`systemctl` shim** ([lohar internals](/docs/under-the-hood/lohar-the-blacksmith/)). The same three commands — `systemctl status`, `journalctl -u`, `systemctl restart` — work the same way regardless of which tier you started from:
```bash
bhatti exec dev -- systemctl status docker
bhatti exec dev -- journalctl -u docker -n 50
bhatti exec dev -- systemctl restart docker
```
If a managed daemon crashes, the shim’s `Restart=on-failure` policy brings it back; you see the crash in `journalctl`. If you edit a config file, `systemctl restart ` picks it up. The shape is the same on every tier — that’s the point of having one shim instead of one bespoke init script per tier.
A few practical points the shim deliberately doesn’t try to replicate from real systemd, because they cost more than they’re worth in a microVM sandbox:
* **No socket activation.** Daemons that ship with `ExecStart=… -H fd://` need a drop-in to bind the socket directly. The docker tier does this for `docker.service`; you only need to know if you write your own units.
* **No D-Bus.** Daemons that default to the `systemd` cgroup driver (notably dockerd) need a config override to use `cgroupfs`. Again — the docker tier handles this; flagged only because the failure mode is opaque (`dial unix /run/systemd/private: …`) if you build a tier from scratch.
* **No `loginctl`, no `journald` binary format.** Single-user sandbox, plain-text per-unit logs in `/var/log/bhatti/`. `journalctl -u ` reads those files; `-f` follows.
The deeper rationale is in [Decisions & learnings](/docs/under-the-hood/decisions/) under “Why no real systemd.”
## Sizing and resources
[Section titled “Sizing and resources”](#sizing-and-resources)
| Tier | Minimum sensible | Comfortable |
| ---------- | ---------------- | ----------- |
| `minimal` | 1 vCPU / 256 MB | 1 / 512 |
| `browser` | 1 / 1024 | 2 / 2048 |
| `docker` | 2 / 2048 | 4 / 4096 |
| `computer` | 2 / 2048 | 4 / 4096 |
KasmVNC’s encoder thread count is sized to `nproc - 1`, so `--cpus 2` is the practical floor for the computer tier (any less and the encoder, X server, XFCE, and Chromium all share one core). For the docker tier, anything memory-intensive (large `buildx` builds, multi-container compose stacks) wants 4+ GB.
## Minimal
[Section titled “Minimal”](#minimal)
The base. Bare Ubuntu 24.04 with curl, sudo, ca-certificates, fuse3, and the `systemd-resolved` apt pin so package installs don’t accidentally pull in a real systemd. lohar is PID 1.
Useful when:
* You’re going to install everything yourself anyway.
* You want the smallest possible rootfs for snapshot/restore latency.
* You’re building a custom image with [`bhatti image save`](/docs/reference/cli/images/save/) and want to start from clean.
The `lohar` user (uid 1000) has passwordless sudo. `bhatti exec` runs as this user. The shim’s `systemctl` / `journalctl` symlinks are wired in at this layer, so any service-aware package you install (openssh-server, postgresql, redis-server, nginx) Just Works under the shim.
```bash
bhatti create --name dev --image minimal
bhatti exec dev -- sudo apt-get update
bhatti exec dev -- sudo apt-get install -y postgresql redis-server
bhatti exec dev -- systemctl status postgresql redis-server
```
## Adding a tier
[Section titled “Adding a tier”](#adding-a-tier)
If your team has a stack that’s repeated often, you can ship a tier of your own. See [Adding a tier](/docs/contributing/adding-a-tier/) for the build script convention, CI matrix entry, and install-flow integration.
## See also
[Section titled “See also”](#see-also)
* [Images & custom builds](/docs/managing/images/) — pull from OCI, import from Docker, save a configured sandbox
* [Adding a tier](/docs/contributing/adding-a-tier/) — build a new system tier from scratch
* [Lohar: the agent inside every VM](/docs/under-the-hood/lohar-the-blacksmith/) — how the `systemctl` shim works
* [Decisions & learnings](/docs/under-the-hood/decisions/) — why no real systemd, what’s intentionally out of scope
# Browser tier
> Headless Chromium with the Chrome DevTools Protocol exposed on :9222.
The `browser` tier ships Chromium (via Playwright’s `headless_shell` build), Playwright itself, and Node 22. Chromium starts at boot with CDP exposed on `:9222` ready to drive — no display server, no X, no VNC. The right tier for scraping, programmatic CDP control, headless tests, and PDF / screenshot rendering.
```bash
bhatti create --name scraper --image browser --cpus 2 --memory 2048
bhatti exec scraper -- curl -s http://localhost:9222/json/version | jq .Browser
# "Chrome/"
```
## What’s in it
[Section titled “What’s in it”](#whats-in-it)
| Component | Source |
| ---------------------------------- | ------------------------------------------------------------------- |
| Chromium (`headless_shell` binary) | Playwright’s pinned download (`~/.cache/ms-playwright/chromium-*/`) |
| Playwright | `npm install -g playwright` |
| Node 22 | NodeSource apt repo |
| Chromium runtime deps | `npx playwright install-deps chromium` |
Playwright’s `headless_shell` is used rather than full Chrome — it’s the dedicated headless build with reliable CDP. The full Chrome binary has known CDP-attach issues in headless mode that show up as silent stream hangs.
## How Chromium is started
[Section titled “How Chromium is started”](#how-chromium-is-started)
Managed by lohar’s `systemctl` shim as `headless-chrome.service` (since v1.11.9). The unit ships with these defaults:
```ini
[Service]
Type=simple
Environment=CHROME_REMOTE_PORT=9222
Environment=CHROME_FLAGS=
EnvironmentFile=-/run/bhatti/config-env
ExecStart=/root/.cache/ms-playwright/chromium_headless_shell-/chrome-linux/headless_shell \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
--remote-debugging-port=${CHROME_REMOTE_PORT} \
--remote-debugging-address=0.0.0.0 \
$CHROME_FLAGS
Restart=on-failure
RestartSec=2s
```
The path to `headless_shell` is resolved at build time by the tier script (Playwright stamps a version-suffixed directory) and baked into the unit. Operator UX is the same as every other tier:
```bash
bhatti exec scraper -- systemctl status headless-chrome
bhatti exec scraper -- journalctl -u headless-chrome -n 50
bhatti exec scraper -- systemctl restart headless-chrome
```
If Chromium crashes, the shim restarts it after 2 s. The crash + restart show up in `journalctl -u headless-chrome`.
## Driving it from outside the sandbox
[Section titled “Driving it from outside the sandbox”](#driving-it-from-outside-the-sandbox)
CDP is HTTP + WebSocket. Publish the port and connect from anywhere:
```bash
bhatti publish scraper -p 9222
# → https://scraper-abc123.bhatti.sh
```
From your host, point any CDP client at the published URL. Playwright on the host:
```js
const browser = await chromium.connectOverCDP('https://scraper-abc123.bhatti.sh');
```
Puppeteer:
```js
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://scraper-abc123.bhatti.sh/devtools/browser/…'
});
```
You can fetch the WebSocket endpoint from `/json/version`:
```bash
curl -s https://scraper-abc123.bhatti.sh/json/version | jq -r .webSocketDebuggerUrl
```
## Driving it from inside the sandbox
[Section titled “Driving it from inside the sandbox”](#driving-it-from-inside-the-sandbox)
`bhatti exec` runs as uid 1000 with Node and Playwright already in `$PATH`. The fastest agent loop is to write your script as a file and exec it:
```bash
bhatti file write scraper /workspace/scrape.js < ./scrape.js
bhatti exec scraper -- node /workspace/scrape.js
```
The Playwright globals (`chromium`, `firefox`, `webkit`) all work, but only Chromium has a running browser to attach to via CDP. To launch fresh Chromium instances from your script (rather than the boot-time one), use `chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-*/chrome-linux/headless_shell' })` — the boot-time instance is a convenience, not a requirement.
## Tunables
[Section titled “Tunables”](#tunables)
Set at create time via `bhatti create --env K=V,K=V`. The values flow into the unit through lohar’s [config-env bridge](/docs/under-the-hood/lohar-the-blacksmith/#config-env-bridge):
| Variable | Default | Effect |
| -------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `CHROME_REMOTE_PORT` | `9222` | CDP port to listen on |
| `CHROME_FLAGS` | `""` | Extra space-separated flags appended to ExecStart (`--user-agent=…`, `--proxy-server=…`, `--lang=…`, etc.) |
```bash
bhatti create --name scraper --image browser \
--env "CHROME_FLAGS=--user-agent=Mozilla/5.0 (compatible; bhatti-bot/1.0)"
```
To edit flags on a running sandbox, drop a unit override and restart:
```bash
bhatti exec scraper -- sudo mkdir -p /etc/systemd/system/headless-chrome.service.d
bhatti exec scraper -- sudo tee /etc/systemd/system/headless-chrome.service.d/custom.conf <<'EOF'
[Service]
Environment=CHROME_FLAGS=--proxy-server=http://my-proxy:3128
EOF
bhatti exec scraper -- sudo systemctl restart headless-chrome
```
## Sizing
[Section titled “Sizing”](#sizing)
| Workload | `--cpus` | `--memory` |
| ------------------------------ | -------: | ---------: |
| Single-page scrapes, CDP drive | 1 | 1024 |
| Concurrent Playwright tests | 2 | 2048 |
| PDF rendering at scale | 2–4 | 2048–4096 |
Chromium is memory-hungry — 512 MB will OOM on any non-trivial page. The 1 GB default minimum is a real floor.
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
| Symptom | Cause | Fix |
| ----------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `curl /json/version` returns connection refused | Chromium failed to start at boot | `systemctl status headless-chrome`; `journalctl -u headless-chrome` for the crash reason |
| Chromium crashed mid-session | crash recovered via `Restart=on-failure`, but flapping if it crashes repeatedly | `systemctl status headless-chrome` shows `(activating)` if currently restarting, `(failed)` if start-limit hit; `journalctl -u headless-chrome` for the underlying error |
| Pages OOM with `Aw, Snap!` | Memory too low | bump `--memory` to 2048+ |
| `chrome-sandbox` permission errors | `--no-sandbox` got dropped from a custom drop-in | check `/etc/systemd/system/headless-chrome.service.d/` — the upstream unit always passes `--no-sandbox` |
| CHROME\_FLAGS not picked up | `--env` syntax: must be comma-separated, not repeated `--env` flags | `bhatti create --env "K1=V1,K2=V2"` (single string, comma-separated) |
## See also
[Section titled “See also”](#see-also)
* [Tiers overview](/docs/managing/tiers/)
* [Docker tier](./docker/) — when you’d combine Docker + headless Chromium
* [Computer tier](./computer/) — when you need a *visible* browser (KasmVNC + Chromium UI)
* [`bhatti publish`](/docs/reference/cli/networking/publish/) — exposing :9222 publicly
# Computer tier
> A full XFCE desktop in a microVM, served over KasmVNC on a single web port.
The `computer` tier is a full graphical Linux desktop: KasmVNC + XFCE + Chromium, served over one HTTP/WebSocket port (`6080`) as a browser-based web client. The right tier for visual agent-driven work (a model that sees a real desktop), demos, and any scenario where “open a browser and look at it” is the interface.
```bash
bhatti create --name desk --image computer --cpus 2 --memory 4096 --disk-size 8192
bhatti publish desk -p 6080
bhatti exec desk -- vnc-creds # username + password
# Open the URL printed by `publish` in your browser, log in.
```
## Why `--cpus 2` is the practical floor
[Section titled “Why --cpus 2 is the practical floor”](#why---cpus-2-is-the-practical-floor)
KasmVNC’s encoder thread count is sized to the guest’s vCPUs (`nproc - 1`, leaving one core for the desktop itself). On `--cpus 1` the encoder, the X server, XFCE, and Chromium all share one core — expect <10 fps and laggy input. Two cores is usable; four is comfortable.
## What’s in it
[Section titled “What’s in it”](#whats-in-it)
| Component | Role |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **KasmVNC** (`Xkasmvnc`) | Combined X server + RFB → WebSocket gateway. Replaces the older Xvfb + x11vnc + noVNC + websockify stack with one binary, one port. |
| **XFCE** (`startxfce4`) | Lightweight desktop environment. Window manager, panel, file manager, app launcher. |
| **Chromium** | The browser. `chromium-browser ` wrapper applies sane flags (`--no-sandbox` because we’re in a VM, `--test-type` to suppress nags). |
| `xdotool`, `scrot`, `xdpyinfo` | Mouse/keyboard input, screenshots, display introspection. Used by the agent-helper scripts below. |
## Credentials
[Section titled “Credentials”](#credentials)
A random 16-character password is generated **on first boot of each sandbox** (not at image-bake time):
* Hashed into `/root/.kasmpasswd` for KasmVNC.
* Stored cleartext in `/root/.vnc/cleartext` (root-only, mode `0600`) for the `vnc-creds` helper.
* Each sandbox gets its own; the published rootfs image carries no shared secret.
* Snapshot/resume preserves the password (the file already exists; firstboot generation is skipped).
Retrieve them anytime:
```bash
bhatti exec desk -- vnc-creds # human-readable
bhatti exec desk -- vnc-creds --json # for scripts/agents
```
Caution
[`bhatti image save`](/docs/reference/cli/images/save/) on a running computer-tier sandbox **bakes the current password into the saved image**. Treat saved-from-running images like any other secret-bearing artifact. If you share such an image, anyone with it has VNC access to every sandbox stamped from it. The clean alternative is to delete `/root/.kasmpasswd` and `/root/.vnc/cleartext` before saving, so firstboot regen kicks in on every new sandbox.
## Tunables
[Section titled “Tunables”](#tunables)
Pass at create time with `--env` (comma-separated):
| Variable | Default | What it controls |
| ---------------- | ----------- | ------------------------------------------------------------------------- |
| `DISPLAY_WIDTH` | `1280` | X server geometry width |
| `DISPLAY_HEIGHT` | `720` | X server geometry height |
| `DISPLAY_DEPTH` | `24` | Colour depth (16 / 24 / 32) |
| `KASM_FRAMERATE` | `60` | Max frames/sec the encoder will emit (matches KasmVNC’s upstream default) |
| `KASM_THREADS` | `nproc - 1` | Encoder thread count; default leaves one vCPU for the desktop |
```bash
bhatti create --name desk --image computer --cpus 4 --memory 4096 \
--env "DISPLAY_WIDTH=1920,DISPLAY_HEIGHT=1080,KASM_FRAMERATE=30"
```
`KASM_FRAMERATE=30` is worth setting on low-bandwidth links — encoder load drops roughly linearly with framerate, and 30 fps is more than enough for desktop work.
## Beyond the env knobs
[Section titled “Beyond the env knobs”](#beyond-the-env-knobs)
KasmVNC has dozens of options bhatti deliberately doesn’t surface (dynamic quality bounds, video-mode thresholds, scaling algorithms, DLP/clipboard policy, etc.). For those, edit `/etc/kasmvnc/kasmvnc.yaml` inside the sandbox and reconnect. The upstream docs are authoritative:
* [Video rendering options](https://github.com/kasmtech/KasmVNC/wiki/Video-Rendering-Options)
* [Stats / control API](https://github.com/kasmtech/KasmVNC/wiki/API)
* [Browser-side tuning](https://github.com/kasmtech/KasmVNC/wiki/Browser-Support)
## Agent helpers
[Section titled “Agent helpers”](#agent-helpers)
Run any of these via `bhatti exec`. `DISPLAY=:99` is pre-set (lohar writes it to `/run/bhatti/env` so exec inherits it), so X-talking helpers just work.
| Helper | Purpose |
| -------------------------------- | ------------------------------------------------ |
| `vnc-creds [--json]` | Print the username and password for this sandbox |
| `screenshot [--base64]` | Capture the current display as PNG |
| `screen-size` | Print the current X resolution |
| `active-window` / `list-windows` | Inspect window state |
| `xdotool …` | Drive mouse/keyboard input |
| `chromium-browser ` | Launch Chromium with sane flags |
The typical agent loop:
```bash
bhatti exec desk -- chromium-browser https://example.com &
sleep 2
bhatti exec desk -- screenshot --base64 > frame.b64
bhatti exec desk -- xdotool key Return
bhatti exec desk -- xdotool type "hello world"
```
## How the desktop is started
[Section titled “How the desktop is started”](#how-the-desktop-is-started)
Managed by lohar’s `systemctl` shim as four units (since v1.11.9), replacing the monolithic `init.sh`:
| Unit | Type | Purpose |
| ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kasmvnc-firstboot.service` | `oneshot` | Per-sandbox password generation + dynamic `KASM_THREADS` default. `ConditionPathExists=!/root/.kasmpasswd` makes it idempotent on snapshot/resume. |
| `kasmvnc.service` | `simple` | `Xkasmvnc :99` — combined X server + RFB-over-WebSocket gateway. Listens on port 6080. `After=kasmvnc-firstboot.service`. `Restart=on-failure`. |
| `xfce-session.service` | `simple` | `startxfce4` desktop session. `After=` + `Requires=kasmvnc.service` so killing the X server cascades. `Restart=on-failure`. |
| `bhatti-display-env.service` | `oneshot` | Writes `DISPLAY=:99` to `/run/bhatti/env` so `bhatti exec` inherits it. |
Activation order at boot: `kasmvnc-firstboot` and `bhatti-display-env` start in the first wave (no `After=`), then `kasmvnc.service`, then `xfce-session.service`.
Operator UX:
```bash
bhatti exec desk -- systemctl status kasmvnc xfce-session
bhatti exec desk -- journalctl -u kasmvnc -n 50
bhatti exec desk -- systemctl restart kasmvnc # cascades: xfce restarts too
bhatti exec desk -- systemctl restart xfce-session # just the desktop, X server stays
```
### What’s deliberately not running
[Section titled “What’s deliberately not running”](#whats-deliberately-not-running)
The pre-v1.11.9 `init.sh` started a system `dbus-daemon`, an orphan session bus via `dbus-launch`, and `pulseaudio`. None of those are started in the systemd-unit model. The reasoning:
* **`dbus-daemon --system`** keeps long-lived inotify watches on `/etc/dbus-1/`, an epoll on its listening socket, and per-connection timers. microVM snapshot/restore doesn’t preserve all kernel-side poller state cleanly on ARM64 — the same reason lohar runs as PID 1 instead of real systemd ([Decisions & learnings](/docs/under-the-hood/decisions/)).
* **`dbus-launch`** in the original init.sh ran a session bus tied to the boot shell’s scope, leaked its address into the `startxfce4` env, and orphaned when init.sh exited. A bug, not a feature. Modern XFCE (4.16+) launches its own per-session bus on demand when it actually needs one.
* **`pulseaudio`** wasn’t connected to any sink and no documented agent flow uses audio.
The `dbus` and `dbus-x11` packages stay installed (libdbus links from XFCE / Chromium), they’re just not auto-started as a system bus. Practical fallout: a few `xfsettingsd` warnings on first XFCE start, Thunar’s D-Bus activation paths are skipped, Chromium’s notifications and password-manager features are unavailable. The desktop still loads.
If you need a system dbus for an app that depends on it, the narrowest fix is to scope it to xfce-session’s lifecycle rather than running at multi-user.target — so the snapshot-risk window is only “while xfce is up”:
```ini
# /etc/systemd/system/dbus-system.service (drop in via the sandbox)
[Unit]
Description=System bus for xfce
Before=xfce-session.service
[Service]
Type=forking
PIDFile=/run/dbus/pid
ExecStartPre=/bin/mkdir -p /run/dbus
ExecStart=/usr/bin/dbus-daemon --system --fork
```
Then `systemctl enable --now dbus-system.service`. We don’t ship this by default.
## Sizing
[Section titled “Sizing”](#sizing)
| Workload | `--cpus` | `--memory` |
| --------------------------------------------------- | -------: | ---------: |
| Light interactive use, agent loops with screenshots | 2 | 2048 |
| Browser + agent + occasional video | 4 | 4096 |
| Multiple Chromium tabs + multimedia | 4 | 4096–6144 |
| Heavy multimedia / many open apps | 6+ | 8192+ |
`--disk-size 8192` is the recommended floor — Chromium’s cache and any web content you accumulate during a session can easily hit 4–5 GB.
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
| Symptom | Cause | Fix |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| KasmVNC web client loads, then shows a red `No password configured for VNC Auth` banner | `-SecurityTypes=None` flag missing from `Xkasmvnc` invocation; image is pre-fix | `sudo bhatti update --tiers computer` on the server |
| HTTP Basic auth challenge appears but the password is wrong | Sandbox was saved-from-image with stale creds | `bhatti exec desk -- sudo rm /root/.kasmpasswd && sudo /etc/bhatti/init.sh` to regen |
| Black screen, no XFCE panel | `startxfce4` failed (usually D-Bus). Check `journalctl -u bhatti` (host side) or `~/.xsession-errors` (guest) | Restart desktop: `bhatti exec desk -- /etc/bhatti/init.sh` |
| Mouse input lags by seconds | Encoder is CPU-starved | Bump `--cpus`; lower `KASM_FRAMERATE`; check `top` inside the sandbox |
| `xdotool` does nothing | `DISPLAY` not set in the exec environment | \`bhatti exec desk — env |
## See also
[Section titled “See also”](#see-also)
* [Tiers overview](/docs/managing/tiers/)
* [`bhatti publish`](/docs/reference/cli/networking/publish/) — exposing port 6080 with a public URL
* [`bhatti share`](/docs/reference/cli/networking/share/) — single-use authenticated session URLs (useful for VNC handoff)
* [Thermal states](/docs/under-the-hood/thermal-states/) — `--keep-hot` for active sessions, to prevent the VM from pausing under your feet
# Docker tier
> Run Docker Engine inside a microVM. Multi-arch buildx, compose, and the operator UX.
The `docker` tier ships Docker Engine, the buildx and compose plugins, containerd, and the kernel-side bits for cross-arch builds (`binfmt_misc` mounted at PID 1, qemu-user emulation available on demand). It’s the right starting point for anything that wants Docker-in-VM — CI runners, image builds, compose stacks, sandboxed `docker run` experiments.
```bash
bhatti create --name ci --image docker --cpus 4 --memory 4096
bhatti exec ci -- docker run hello-world
```
That’s the entire setup. There is no `docker setup`, no daemon to start by hand, no socket permission dance — the tier wires all of it at build time.
## What’s in it
[Section titled “What’s in it”](#whats-in-it)
| Component | Version | Source |
| --------------------------------------- | -------------- | -------------------------------------- |
| Docker Engine (`dockerd`, `docker` CLI) | 29.x | `docker-ce` from Docker’s apt repo |
| `containerd` | latest stable | `containerd.io` from Docker’s apt repo |
| `docker buildx` plugin | 0.34.x | `docker-buildx-plugin` |
| `docker compose` plugin | latest | `docker-compose-plugin` |
| `iptables-legacy` | from Ubuntu | configured as the default |
| `binfmt_misc` filesystem | kernel feature | mounted by lohar at PID 1 |
The custom bhatti kernel enables every flag dockerd needs at runtime — bridge networking, veth, overlayfs, netfilter conntrack and security tables, and `BINFMT_MISC` for cross-arch emulation. See [Kernel](/docs/contributing/kernel/) for the full list and rationale.
## How dockerd is managed
[Section titled “How dockerd is managed”](#how-dockerd-is-managed)
The tier doesn’t ship its own `docker.service`. It reuses the one that comes with the `docker-ce` package and customises two things via a drop-in at `/etc/systemd/system/docker.service.d/bhatti.conf`:
```ini
[Service]
# Replace upstream `-H fd://` (which requires systemd socket activation
# lohar's shim deliberately doesn't implement) with a direct socket bind.
ExecStart=
ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock --containerd=/run/containerd/containerd.sock
# Pick up `bhatti create --env DOCKER_*=...` overrides.
EnvironmentFile=-/run/bhatti/config-env
# Widen the socket so `bhatti exec` (uid 1000, no supplementary groups)
# can reach it. Safe inside an isolated single-user microVM.
ExecStartPost=/bin/chmod 666 /var/run/docker.sock
Restart=on-failure
RestartSec=2s
```
And `/etc/docker/daemon.json`:
```json
{ "exec-opts": ["native.cgroupdriver=cgroupfs"] }
```
Both overrides exist because the shim is **not** real systemd. Two specific gaps, both deliberate per [Decisions & learnings](/docs/under-the-hood/decisions/):
> **Why `-H fd://` is replaced.** Real `docker.service` listens via a paired `docker.socket` unit; systemd binds the socket and hands the fd to dockerd. The shim doesn’t implement socket activation (“a lot of code for marginal benefit — services start fast in our VMs anyway”). The drop-in tells dockerd to bind the socket itself.
> **Why `cgroupfs` instead of `systemd`.** Docker 29 defaults to `native.cgroupdriver=systemd`, which talks to systemd over D-Bus during every container create. The microVM has no D-Bus (libpam-systemd is pinned out of the rootfs to keep snapshot/restore safe). Without the override, every `docker run` dies in `runc` with `dial unix /run/systemd/private: connect: no such file`.
You don’t need to think about any of this in normal use. The drop-in is in place from boot one.
## The operator UX
[Section titled “The operator UX”](#the-operator-ux)
```bash
bhatti exec dev -- systemctl status docker
# ● docker.service - Docker Application Container Engine
# Active: active (running, PID 310)
# …
bhatti exec dev -- journalctl -u docker -n 50
bhatti exec dev -- journalctl -u docker -f # follow
bhatti exec dev -- systemctl restart docker
bhatti exec dev -- systemctl is-active docker # exits 0 if active
bhatti exec dev -- systemctl is-failed docker # exits 0 if failed
```
If dockerd crashes, the shim restarts it (`Restart=on-failure`, 2 s back-off). The failure shows up in `journalctl -u docker`. There is no separate logs file you have to remember; everything daemon-level is in the journal.
`docker logs ` (per-container application logs) is unchanged from upstream Docker — those flow through containerd, not the shim.
## Environment knobs
[Section titled “Environment knobs”](#environment-knobs)
dockerd inherits anything you pass to `bhatti create --env`. The bridge happens through `/run/bhatti/config-env`, which lohar writes from the config drive at boot and the unit reads via `EnvironmentFile=`.
```bash
bhatti create --name reg --image docker \
--env "DOCKER_REGISTRY_MIRROR=https://mirror.example.com,DOCKER_OPTS=--debug"
```
`--env` takes a **single comma-separated list** (`K=V,K=V`), not a repeated flag.
Recognised env vars today:
| Variable | Effect |
| -------------------------------- | ---------------------------------------------------------------------- |
| Anything starting with `DOCKER_` | Visible in `dockerd`’s environment; useful for env-driven docker hooks |
The drop-in deliberately doesn’t translate env vars into dockerd flags. If you need to change daemon flags themselves, write a second drop-in or edit `/etc/docker/daemon.json` inside the sandbox and `systemctl restart docker`.
## Socket permissions
[Section titled “Socket permissions”](#socket-permissions)
`/var/run/docker.sock` is `0666` after boot — anyone in the sandbox can talk to dockerd. This is the world-writable workaround mentioned in the drop-in. The reasoning:
* `bhatti exec` runs as uid 1000 (the `lohar` user) without supplementary group membership, so the standard `docker` group ACL doesn’t apply.
* The VM is the security boundary, not the docker group. Inside, it’s a single-user sandbox.
* Hardening this further would require lohar to preserve supplementary groups across exec, which is a separate change that hasn’t shipped yet.
If you re-mount the rootfs read-write and tighten the socket yourself, expect to also write a unit that wraps `bhatti exec` with `sg docker -c '…'`.
## Multi-arch builds with buildx
[Section titled “Multi-arch builds with buildx”](#multi-arch-builds-with-buildx)
Same-arch builds are native and need no setup:
```bash
bhatti exec dev -- docker buildx build -t me/app:latest .
```
Cross-arch builds need one extra command — `tonistiigi/binfmt --install all` — to populate the kernel’s `binfmt_misc` handler table with qemu-user interpreters:
```bash
bhatti exec dev -- docker run --privileged --rm tonistiigi/binfmt --install all
bhatti exec dev -- docker buildx create --use --name xb --driver docker-container
bhatti exec dev -- docker buildx build --platform linux/amd64,linux/arm64 \
-t me/app:multi --push .
```
### What that one extra command actually does
[Section titled “What that one extra command actually does”](#what-that-one-extra-command-actually-does)
Three layers have to line up for cross-arch builds to work:
| Layer | Provided by | State on a fresh docker sandbox |
| -------------------------------------------------- | ----------------------------- | ------------------------------- |
| `CONFIG_BINFMT_MISC=y` in the kernel | bhatti kernel | ✅ on |
| `/proc/sys/fs/binfmt_misc` filesystem mounted | lohar at PID 1 | ✅ mounted |
| Per-arch handler registrations + qemu interpreters | `tonistiigi/binfmt` container | ❌ empty until you run it |
The kernel knows it could dispatch foreign-arch ELFs to a userspace interpreter, but it has no interpreters and no mapping yet. The `tonistiigi/binfmt` image carries pre-built static qemu binaries (`qemu-aarch64`, `qemu-arm`, `qemu-riscv64`, …) and uses the kernel’s **fix-binary (`F`) flag** when registering, which makes the kernel open the interpreter and keep an fd to it. After registration the container exits, the qemu binaries are gone, the kernel’s open fds keep working.
You run the command once per sandbox. Registrations survive snapshot/restore (they live in kernel state, not on disk).
### Performance: when emulation is fine, when it isn’t
[Section titled “Performance: when emulation is fine, when it isn’t”](#performance-when-emulation-is-fine-when-it-isnt)
qemu-user emulation is correct but slow — typically 5–20× slower than native for CPU-bound work. Reasonable rules of thumb:
| Workload | Cross-arch emulation | Native-per-arch |
| --------------------------------------------------------- | ------------------------- | --------------- |
| Single-image dev builds, occasional cross-arch validation | 👍 fine | overkill |
| CI pushing release images on every merge | 😬 painful (multi-minute) | 👍 right answer |
| Heavily CPU-bound layers (compilers, native deps, ML) | ❌ avoid | 👍 right answer |
| `apt-get install`-heavy layers (mostly disk + network) | 👌 acceptable | 👍 faster |
For real CI, run one bhatti sandbox per native arch and stitch them with buildx:
```bash
# Driver machine — orchestrates two remote builders.
docker buildx create --name xb --platform linux/amd64 ssh://user@amd64-sandbox-host
docker buildx create --append --name xb --platform linux/arm64 ssh://user@arm64-sandbox-host
docker buildx use xb
docker buildx build --platform linux/amd64,linux/arm64 --push -t me/app:x .
```
(Server-side requires either an SSH driver or a TCP-listening dockerd that you publish via `bhatti publish`.) Each platform builds natively on a matching host; buildx writes the multi-arch manifest. bhatti’s [warm-wake](/docs/under-the-hood/thermal-states/) (\~4 ms) makes this cheap to leave idle between runs.
## Compose
[Section titled “Compose”](#compose)
`docker-compose-plugin` is installed, so `docker compose up` works as you’d expect. There’s no extra wiring.
```bash
bhatti create --name stack --image docker --cpus 4 --memory 4096
bhatti file write stack /workspace/compose.yaml < ./compose.yaml
bhatti exec stack -- docker compose -f /workspace/compose.yaml up -d
bhatti publish stack -p 8080 # if compose exposes :8080 on the host
```
For long-running compose stacks, consider `--keep-hot` on `bhatti create` so the [thermal manager](/docs/under-the-hood/thermal-states/) doesn’t pause the VM during idle periods (TCP connections from the public preview URL survive pause, but new connections during pause cost a wake).
## Networking
[Section titled “Networking”](#networking)
The bhatti kernel ships `iptables-legacy` (not nft). Docker’s bridge driver expects either, and the tier sets the legacy alternatives at build time. If you install a package that switches the alternatives back to nft, dockerd will fail to set up bridge networking. To recover:
```bash
bhatti exec dev -- sudo update-alternatives --set iptables /usr/sbin/iptables-legacy
bhatti exec dev -- sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
bhatti exec dev -- sudo systemctl restart docker
```
Inside the sandbox, dockerd creates its own `docker0` bridge. Container-to-container networking, port publishing inside the VM (`docker run -p 8080:80 ...`), and DNS for container names all work normally. The bhatti-level publish (`bhatti publish dev -p 8080`) targets the sandbox’s host-side port; if a container binds to `0.0.0.0:8080` inside, it’s reachable.
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
| Symptom | Cause | Fix |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `docker run` hangs / `dial unix /run/systemd/private` in logs | daemon.json missing or wrong cgroup driver | `cat /etc/docker/daemon.json` should say `cgroupfs`; if not, write it and `systemctl restart docker` |
| `docker buildx: unknown command` | `docker-buildx-plugin` not installed (very old image) | `sudo apt-get install -y docker-buildx-plugin` or `sudo bhatti update --tiers docker` on the server |
| `tonistiigi/binfmt --install all` reports success but cross-arch builds fail | `/proc/sys/fs/binfmt_misc` not mounted (very old lohar) | Verify with \`mount |
| `permission denied` on `/var/run/docker.sock` from `bhatti exec` | socket mode regressed after `systemctl restart docker` and ExecStartPost didn’t fire | `systemctl status docker` to confirm active; if active but socket is `0660`, the drop-in is missing — check `/etc/systemd/system/docker.service.d/bhatti.conf` |
| `docker run …` fails with iptables errors | alternatives switched to nft | re-set to legacy (see [Networking](#networking)) |
| Cross-arch container runs but exits with `exec format error` | qemu handler not registered for that arch | re-run `tonistiigi/binfmt --install all`; check `ls /proc/sys/fs/binfmt_misc/` for `qemu-` |
| `docker.service` repeatedly entering “activating” | `dockerd` not sending `READY=1` (Type=notify) — usually a containerd issue | `systemctl status containerd`; check `journalctl -u containerd` |
## Resource sizing
[Section titled “Resource sizing”](#resource-sizing)
| Workload | `--cpus` | `--memory` |
| ------------------------------------------------------------- | -------: | ---------: |
| `docker run` of small images (`hello-world`, alpine tools) | 2 | 2048 |
| Light compose stacks (3–5 small services) | 2–4 | 2048–4096 |
| `buildx build` of a typical app image | 4 | 4096 |
| Cross-arch `buildx build` with qemu emulation | 4 | 4096–8192 |
| Heavy CI builds (large dependency graphs, native compilation) | 8 | 8192+ |
Memory matters more than CPU for most builds — overlayfs page cache, build context, and intermediate layers all live in the guest. dockerd itself runs comfortably in 256 MB; anything above that is your workload.
## See also
[Section titled “See also”](#see-also)
* [Tiers overview](/docs/managing/tiers/) — what each tier is for
* [Adding a tier](/docs/contributing/adding-a-tier/) — build your own
* [Kernel](/docs/contributing/kernel/) — flags enabled for Docker, why
* [Lohar: systemctl shim](/docs/under-the-hood/lohar-the-blacksmith/) — how unit files are managed without real systemd
* [Decisions & learnings](/docs/under-the-hood/decisions/) — why no socket activation, why no D-Bus
* [Thermal states](/docs/under-the-hood/thermal-states/) — `--keep-hot` and what idle behaviour to expect for long-running stacks
# Users & Auth
> Multi-tenant model — per-user API keys, resource caps, network isolation, sandbox scoping.
bhatti is multi-tenant by default. Every user gets:
* An API key (`bht_<64-hex>`).
* Their own network: each user’s sandboxes get a dedicated `bhatti-netd` gateway, so sandboxes from different users never share a network.
* Per-user caps on sandbox count, per-sandbox CPU, and per-sandbox memory.
* Their own secrets, volumes, snapshots, and images.
Users are created server-side, with `sudo bhatti user create`. The CLI commands all live in [Server & admin](/docs/reference/cli/admin/) — this page covers the model, not the surface.
You won’t usually run `user create` for yourself. The install script (`curl -fsSL bhatti.sh/install | sudo bash`) creates an `admin` user during setup and writes its API key to your `~/.bhatti/config.yaml`. The first manual `user create` you run is for adding a teammate — see [Self-hosting → adding teammates](/docs/self-hosting/#adding-teammates).
## How keys work
[Section titled “How keys work”](#how-keys-work)
The server stores only the SHA-256 hash of each API key. The plaintext is shown once at creation and once on rotation; if the user loses it, [`rotate-key`](/docs/reference/cli/admin/user-rotate-key/) is the only path back. There’s no recovery.
Auth is `Authorization: Bearer ` on every API request except `/health` and `/_shell/`. WebSocket connections use the same header. There’s no `?token=…` query-param auth — keys never end up in URLs (and therefore not in proxy logs).
The web shell is the one place a token appears in a URL — embedded in the **fragment** (`#token=…`), which never leaves the browser. The page extracts it client-side and uses it as a Bearer header on the WebSocket.
## Resource caps
[Section titled “Resource caps”](#resource-caps)
Every user has three per-user limits:
| Limit | Meaning |
| --------------------------- | ------------------------------------------------------ |
| `max_sandboxes` | Concurrent (non-destroyed) sandboxes the user can own. |
| `max_cpus_per_sandbox` | Cap on `--cpus` at create time. |
| `max_memory_mb_per_sandbox` | Cap on `--memory` at create time. |
Lowering a cap doesn’t affect existing sandboxes. New ones are rejected with `403` if they exceed the cap, or `429` if the user is at their `max_sandboxes`.
Storage is not currently capped per-user — disk usage grows freely until the host fills up. Track per-user volume size with `bhatti volume list` and the relevant `--data-dir`.
## Sandbox scoping
[Section titled “Sandbox scoping”](#sandbox-scoping)
The API enforces strict per-user isolation:
* `GET /sandboxes` returns only sandboxes the authenticated user created.
* Looking up someone else’s sandbox by ID returns `404` (intentional — the existence isn’t leaked).
* The user can’t exec, file-read, or destroy a sandbox they don’t own.
This is consistent across every endpoint that takes a sandbox ID.
## Network isolation
[Section titled “Network isolation”](#network-isolation)
User isolation isn’t a host firewall — it’s enforced at two layers. The API layer scopes every query to the authenticated user (see [Sandbox scoping](#sandbox-scoping) above). And at the data plane, each user’s sandboxes get their own [`bhatti-netd`](/docs/under-the-hood/networking/) gateway — a per-owner userspace gVisor netstack — so cross-user traffic never shares a network in the first place. There are no host bridges or iptables rules to reason about.
A user’s own sandboxes *can* reach each other (e.g. an agent sandbox can curl a worker sandbox), because they share one gateway; that traffic is still mediated and policed by netd rather than flowing peer-to-peer.
The `subnet` index you may see in some CLI output is vestigial — it survives from the v1 per-user-bridge design and now just picks a private address octet for the gateway. It isn’t a Linux bridge and you don’t need to think about it.
## See also
[Section titled “See also”](#see-also)
* [`bhatti user create`](/docs/reference/cli/admin/user-create/), [`list`](/docs/reference/cli/admin/user-list/), [`rotate-key`](/docs/reference/cli/admin/user-rotate-key/), [`delete`](/docs/reference/cli/admin/user-delete/)
* [Networking](/docs/under-the-hood/networking/) — the per-owner `bhatti-netd` gateway, policed egress, and host isolation
# Volumes
> Persistent ext4 volumes — attach semantics, mount restrictions, and the detached-state requirement.
Persistent volumes are ext4 filesystems that survive sandbox destruction. Create one, attach it to a sandbox, destroy the sandbox — the data is still there.
The CLI surface is in [Volumes reference](/docs/reference/cli/volumes/). This page covers the rules — when you can attach, what mount paths are allowed, and which operations require the volume to be unattached.
## Attaching at create time
[Section titled “Attaching at create time”](#attaching-at-create-time)
Volumes are mounted by lohar during sandbox boot. Specify them with `--volume` (repeatable):
```bash
bhatti volume create --name workspace --size 5120
bhatti create --name dev --volume workspace:/workspace
```
The format is `:[:ro]`:
```bash
bhatti create --name reader --volume workspace:/data:ro
```
`:ro` mounts read-only. Multiple sandboxes can mount the same volume read-only at the same time — useful for shared base data. Read-write attachment is exclusive: only one sandbox can have it as read-write at any moment.
You can attach more than one volume per sandbox by repeating `--volume`.
## Mount path restrictions
[Section titled “Mount path restrictions”](#mount-path-restrictions)
Mount paths must be **absolute**. The guest agent rejects anything else.
You also can’t shadow system paths. Specifically, the following are blocked:
```plaintext
/proc /sys /dev /etc /usr /bin /sbin /lib /lib64 /run /var/run
```
Use a fresh subtree like `/workspace`, `/data`, `/srv/` — anywhere outside the system hierarchy.
## Detached state
[Section titled “Detached state”](#detached-state)
Four operations require the volume to be **detached** (no active sandbox attachments):
* [`bhatti volume delete`](/docs/reference/cli/volumes/delete/)
* [`bhatti volume resize`](/docs/reference/cli/volumes/resize/)
* [`bhatti volume clone`](/docs/reference/cli/volumes/clone/)
* [`bhatti volume restore`](/docs/reference/cli/volumes/restore/)
The server returns `409 Conflict` if any sandbox has the volume mounted. To detach, either destroy the sandbox or [`bhatti stop`](/docs/reference/cli/sandbox/stop/) it (stopping detaches volumes; starting reattaches).
[`bhatti volume backup`](/docs/reference/cli/volumes/backup/) is the exception — it works while the volume is attached. The trade-off is consistency: writes in flight at backup time may be captured mid-update. For a guaranteed-clean backup, stop the sandbox first.
## Backups
[Section titled “Backups”](#backups)
Volume backups go to S3-compatible storage (AWS, Backblaze B2, R2, MinIO, etc.). They require a `backup` block in the server config; without it, backup-related commands return `501`.
```bash
bhatti volume backup workspace
bhatti volume backup-list workspace
bhatti volume restore workspace --backup-id bk_a1b2c3d4
```
Schedule regular backups with retention via the `backup.schedule` config — see [Configuration](/docs/reference/config/#backup).
## See also
[Section titled “See also”](#see-also)
* [Volumes reference](/docs/reference/cli/volumes/) — every command
* [`bhatti create --volume`](/docs/reference/cli/sandbox/create/) — attach syntax
* [Configuration: backup](/docs/reference/config/#backup) — S3 setup
# CLI Reference
> Every bhatti command, global flags, environment variables, output formats, and exit codes.
`bhatti` is a single binary. `bhatti serve` runs the daemon; everything else is a CLI client that talks to the daemon’s HTTP API. All sandbox commands accept the sandbox name or ID interchangeably.
```bash
bhatti setup # configure endpoint + API key
bhatti create --name dev # create a sandbox
bhatti exec dev -- echo hello # run a command
bhatti shell dev # interactive shell (Ctrl+\ to detach)
bhatti destroy dev # clean up
```
## Operations
[Section titled “Operations”](#operations)
| Command | Syntax | Description |
| -------------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------- |
| **Core** | | |
| [`create`](/docs/reference/cli/sandbox/create/) | `bhatti create [flags]` | Create a new sandbox VM |
| [`destroy`](/docs/reference/cli/sandbox/destroy/) | `bhatti destroy [-y]` | Destroy a sandbox (alias: `rm`) |
| [`edit`](/docs/reference/cli/sandbox/edit/) | `bhatti edit [--keep-hot \| --allow-cold]` | Update mutable settings |
| [`exec`](/docs/reference/cli/exec/exec/) | `bhatti exec [--] ` | Run a command in a sandbox |
| [`inspect`](/docs/reference/cli/sandbox/inspect/) | `bhatti inspect ` | Show sandbox details (alias: `info`) |
| [`list`](/docs/reference/cli/sandbox/list/) | `bhatti list [-o wide]` | List sandboxes (alias: `ls`) |
| [`ports`](/docs/reference/cli/exec/ports/) | `bhatti ports ` | List listening ports inside a sandbox |
| [`ps`](/docs/reference/cli/exec/ps/) | `bhatti ps ` | List active sessions in a sandbox |
| [`share`](/docs/reference/cli/networking/share/) | `bhatti share [--revoke]` | Generate a web shell URL |
| [`shell`](/docs/reference/cli/exec/shell/) | `bhatti shell [--new]` | Open an interactive shell (alias: `sh`) |
| [`start`](/docs/reference/cli/sandbox/start/) | `bhatti start [--force]` | Resume a stopped sandbox |
| [`stop`](/docs/reference/cli/sandbox/stop/) | `bhatti stop ` | Snapshot and stop a sandbox |
| **Files** | | |
| [`file read`](/docs/reference/cli/files/read/) | `bhatti file read ` | Read a file from a sandbox |
| [`file write`](/docs/reference/cli/files/write/) | `bhatti file write ` | Write a file (reads from stdin) |
| [`file ls`](/docs/reference/cli/files/ls/) | `bhatti file ls ` | List directory contents |
| **Networking** | | |
| [`publish`](/docs/reference/cli/networking/publish/) | `bhatti publish -p [-a ] [--shell]` | Publish a port with a public URL |
| [`unpublish`](/docs/reference/cli/networking/unpublish/) | `bhatti unpublish -p ` | Remove a published port |
| **Images** | | |
| [`image list`](/docs/reference/cli/images/list/) | `bhatti image list` | List available rootfs images |
| [`image pull`](/docs/reference/cli/images/pull/) | `bhatti image pull [--name N] [--auth U:T]` | Pull from a public OCI registry |
| [`image import`](/docs/reference/cli/images/import/) | `bhatti image import \| --tar [--name N]` | Import a Docker image or tarball |
| [`image save`](/docs/reference/cli/images/save/) | `bhatti image save --name ` | Save a sandbox’s filesystem as an image |
| [`image delete`](/docs/reference/cli/images/delete/) | `bhatti image delete [-y]` | Delete an image |
| [`image share`](/docs/reference/cli/images/share/) | `bhatti image share --user ...` | Share an image with specific users |
| [`image unshare`](/docs/reference/cli/images/unshare/) | `bhatti image unshare --user ...` | Revoke image access from users |
| **Volumes** | | |
| [`volume create`](/docs/reference/cli/volumes/create/) | `bhatti volume create --name --size ` | Create a persistent volume |
| [`volume list`](/docs/reference/cli/volumes/list/) | `bhatti volume list` | List volumes |
| [`volume delete`](/docs/reference/cli/volumes/delete/) | `bhatti volume delete [-y]` | Delete a volume |
| [`volume resize`](/docs/reference/cli/volumes/resize/) | `bhatti volume resize --size ` | Resize a volume (grow only) |
| [`volume clone`](/docs/reference/cli/volumes/clone/) | `bhatti volume clone --name ` | Clone a volume (point-in-time copy) |
| [`volume backup`](/docs/reference/cli/volumes/backup/) | `bhatti volume backup ` | Back up a volume to S3 |
| [`volume backup-list`](/docs/reference/cli/volumes/backup-list/) | `bhatti volume backup-list ` | List backups for a volume |
| [`volume restore`](/docs/reference/cli/volumes/restore/) | `bhatti volume restore --backup-id ` | Restore a volume from a backup |
| [`volume backup-delete`](/docs/reference/cli/volumes/backup-delete/) | `bhatti volume backup-delete [-y]` | Delete a volume backup |
| **Secrets** | | |
| [`secret set`](/docs/reference/cli/secrets/set/) | `bhatti secret set ` | Create or update a secret |
| [`secret list`](/docs/reference/cli/secrets/list/) | `bhatti secret list` | List secret names |
| [`secret delete`](/docs/reference/cli/secrets/delete/) | `bhatti secret delete [-y]` | Delete a secret |
| **Snapshots** | | |
| [`snapshot create`](/docs/reference/cli/snapshots/create/) | `bhatti snapshot create --name ` | Checkpoint a running sandbox |
| [`snapshot list`](/docs/reference/cli/snapshots/list/) | `bhatti snapshot list` | List snapshots |
| [`snapshot resume`](/docs/reference/cli/snapshots/resume/) | `bhatti snapshot resume [--name ]` | Resume from a snapshot into a new sandbox |
| [`snapshot delete`](/docs/reference/cli/snapshots/delete/) | `bhatti snapshot delete [-y]` | Delete a snapshot |
| **Server & admin** | | |
| [`serve`](/docs/reference/cli/admin/serve/) | `bhatti serve` | Start the bhatti daemon |
| [`setup`](/docs/reference/cli/admin/setup/) | `bhatti setup` | Configure CLI endpoint and API key |
| [`version`](/docs/reference/cli/admin/version/) | `bhatti version` | Print version and check for updates |
| [`update`](/docs/reference/cli/admin/update/) | `bhatti update [--cli-only] [--tiers ]` | Update bhatti |
| [`completion`](/docs/reference/cli/admin/completion/) | `bhatti completion ` | Generate shell completion script |
| [`user create`](/docs/reference/cli/admin/user-create/) | `bhatti user create --name [--max-...]` | Create a user (server-only) |
| [`user list`](/docs/reference/cli/admin/user-list/) | `bhatti user list` | List users (server-only) |
| [`user rotate-key`](/docs/reference/cli/admin/user-rotate-key/) | `bhatti user rotate-key ` | Rotate a user’s API key (server-only) |
| [`user delete`](/docs/reference/cli/admin/user-delete/) | `bhatti user delete [-y]` | Delete a user (server-only) |
| [`admin status`](/docs/reference/cli/admin/admin-status/) | `bhatti admin status` | One-shot system overview (server-only) |
| [`admin events`](/docs/reference/cli/admin/admin-events/) | `bhatti admin events [--type T] [--since 24h]` | Query the event log (server-only) |
| [`admin metrics`](/docs/reference/cli/admin/admin-metrics/) | `bhatti admin metrics [--since 1h]` | Query metrics snapshots (server-only) |
## Global flags
[Section titled “Global flags”](#global-flags)
These flags are accepted by every command.
| Flag | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url ` | API endpoint (overrides config and `BHATTI_URL`). |
| `--token ` | API key (overrides config and `BHATTI_TOKEN`). |
| `--data-dir ` | Path to the server’s data directory. Required for `bhatti user *` and `bhatti admin *` since those operate directly on the local SQLite database. |
| `--json` | Print machine-readable JSON instead of the default human format. Universally supported. |
| `--timing` | Print a per-request timing breakdown (DNS, connect, TLS, server, transfer, total) to stderr after the command runs. |
| `-h`, `--help` | Show help for any command. |
## Environment variables
[Section titled “Environment variables”](#environment-variables)
| Variable | Used by | Description |
| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `BHATTI_URL` | CLI | API endpoint. Falls back when `--url` is not set and the config file has no `api_url`. |
| `BHATTI_TOKEN` | CLI | API key. Same fallback semantics as `BHATTI_URL`. |
| `BHATTI_CONFIG` | CLI + server | Override path to the config file. When set, layered loading is bypassed and only this file is read. |
| `BHATTI_LOG_LEVEL` | server (`bhatti serve`) | `debug`, `info` (default), `warn`, `error`. |
| `BHATTI_FORCE_STREAM` | CLI (`bhatti exec`) | Set to `1` to force NDJSON streaming output even when stdout is not a TTY. Useful in CI logs where you want real-time progress. |
## Configuration precedence
[Section titled “Configuration precedence”](#configuration-precedence)
Per-call flag → config file → environment variable → built-in default.
```plaintext
--url (highest)
api_url: (in config file)
BHATTI_URL= (env var)
http://localhost:8080 (default, lowest)
```
The config file is the primary source — `bhatti setup` writes it and most users never touch the rest. Environment variables are the fallback for CI pipelines and scripts.
See [Configuration](/docs/reference/config/) for the file format and layered loading rules (`/etc/bhatti/config.yaml` for system settings, `~/.bhatti/config.yaml` for client credentials).
## Output formats
[Section titled “Output formats”](#output-formats)
**Human (default).** Compact, TTY-friendly. `bhatti list` prints columns; `bhatti inspect` prints kubectl-`describe`-style details with live disk usage on running VMs.
**`--json`.** Universally supported. Drops all human formatting; emits the raw API response (for queries) or a small status object (for mutations).
**`bhatti list -o wide`.** Adds CPUs, memory, disk, and image columns to `list`. Equivalent to `kubectl get -o wide`.
**Streaming exec output.** `bhatti exec` auto-detects how to format its output:
* Stdout is a TTY → NDJSON streaming, line-by-line, flushed immediately.
* Stdout is a pipe or `--json` is set → buffered JSON with `{exit_code, stdout, stderr}`.
* Override with `BHATTI_FORCE_STREAM=1` to force streaming when piping.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Code | Meaning |
| ----- | -------------------------------------------------------------------------------------------------------------------------------- |
| `0` | Success. |
| `1` | CLI or API error (see stderr for details and recovery hints). |
| `` | `bhatti exec` only — the child process’s exit code is forwarded verbatim, so a script’s `$?` reflects the command’s true status. |
Destructive commands (`destroy`, `volume delete`, `image delete`, `snapshot delete`, `volume backup-delete`, `secret delete`, `user delete`) require confirmation. Pass `-y` / `--yes` to skip the prompt; without `-y` in a non-interactive shell, the command exits `1`.
## Shell completion
[Section titled “Shell completion”](#shell-completion)
```bash
bhatti completion bash > /etc/bash_completion.d/bhatti
bhatti completion zsh > "${fpath[1]}/_bhatti"
bhatti completion fish > ~/.config/fish/completions/bhatti.fish
```
Sandbox-name completion uses a local cache (`/tmp/bhatti-completions-`) updated by `create`, `destroy`, and `list`. It never hits the network — instant, works offline.
# Server & admin
> Daemon, configuration, version, updates, user management, observability.
Commands that run on the bhatti server itself, plus a couple that work from any client (`setup`, `version`, `completion`, `update`).
## Categories
[Section titled “Categories”](#categories)
| | |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Daemon** | [`serve`](/docs/reference/cli/admin/serve/) |
| **Client config** | [`setup`](/docs/reference/cli/admin/setup/), [`version`](/docs/reference/cli/admin/version/), [`completion`](/docs/reference/cli/admin/completion/) |
| **Updates** | [`update`](/docs/reference/cli/admin/update/) |
| **User management** | [`user create`](/docs/reference/cli/admin/user-create/), [`user list`](/docs/reference/cli/admin/user-list/), [`user rotate-key`](/docs/reference/cli/admin/user-rotate-key/), [`user delete`](/docs/reference/cli/admin/user-delete/) |
| **Observability** | [`admin status`](/docs/reference/cli/admin/admin-status/), [`admin events`](/docs/reference/cli/admin/admin-events/), [`admin metrics`](/docs/reference/cli/admin/admin-metrics/) |
## Server-only commands
[Section titled “Server-only commands”](#server-only-commands)
`user *` and `admin *` operate **directly on the server’s SQLite database** (`/var/lib/bhatti/state.db`). They aren’t HTTP API calls — they need filesystem access to the database. Run them on the server, typically with `sudo`.
`serve` runs the daemon. Also server-only by definition (requires KVM and root).
The remaining commands (`setup`, `version`, `update`, `completion`) work from any client.
## Quick patterns
[Section titled “Quick patterns”](#quick-patterns)
```bash
# Bootstrap a new user (server)
sudo bhatti user create --name alice --max-sandboxes 10
# Check overall health (server)
sudo bhatti admin status
# Find recent thermal pauses (server)
sudo bhatti admin events --type thermal --since 24h
# Update the CLI on a developer machine
bhatti update
# Update everything on the server (CLI, kernel, rootfs, lohar)
sudo bhatti update
```
# bhatti admin events
> Query the event log. Filter by type, user, sandbox, time range.
# bhatti admin events server only
Query the structured event log.
## Synopsis
```
sudo bhatti admin events
[--type ] [--user ] [--sandbox ]
[--since ] [--limit ] [--count]
```
## Description
Returns rows from the audit/observability event log stored in the local database. Each event captures a moment in the system: sandbox lifecycle changes, thermal transitions, snapshot operations, image pulls, auth failures, proxy errors, daemon start/shutdown, and more.
Default output is one row per event with timestamp, type, user, sandbox, and a per-event-type summary line. JSON output includes the full meta object.
`--type` is a **prefix match**, so `--type thermal` matches `thermal.pause`, `thermal.wake`, `thermal.snapshot`, etc.
`--since` accepts:
* Relative durations: `30s`, `15m`, `24h`, `7d`.
* Absolute dates: `2026-04-01` (UTC midnight), `2026-04-01T12:00:00`, full RFC3339.
`--user` and `--sandbox` accept either the name or the internal ID. Names are easier; IDs are stable across renames.
`--count` returns a number-only response — useful for alerting/scripting (`if [[ $(bhatti admin events --type auth.failed --since 5m --count) -gt 10 ]]; then ...`).
## Examples
```bash
# Last 50 events of any kind
sudo bhatti admin events
```
```bash
# All thermal events in the last day
sudo bhatti admin events --type thermal --since 24h
```
```bash
# Auth failures in the last hour, count only
sudo bhatti admin events --type auth.failed --since 1h --count
```
```text
3
```
```bash
# Everything Alice did this week
sudo bhatti admin events --user alice --since 7d
```
```bash
# Drill into one sandbox's history
sudo bhatti admin events --sandbox dev --since 24h
```
```bash
# JSON for piping into jq
sudo bhatti admin events --type sandbox.destroyed --since 7d --json \
| jq '[.[] | .meta.lifetime_s] | add / length' # avg sandbox lifetime
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--type ` | — | Filter by event type. Prefix match (`thermal` matches `thermal.*`). | | `--user ` | — | Filter by user. Name or user ID. | | `--sandbox ` | — | Filter by sandbox. Name or ID. | | `--since ` | — | Earliest event to include. Relative (`24h`, `7d`) or absolute (`2026-04-01`). | | `--limit ` | `50` | Maximum number of events to return. | | `--count` | `false` | Return only the count of matching events, not the rows. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti admin status`](/docs/reference/cli/admin/admin-status/) — shows the most recent \~10 events inline
* [`bhatti admin metrics`](/docs/reference/cli/admin/admin-metrics/) — counter-based view
# bhatti admin metrics
> Time-bucketed metrics — request rates, errors, thermal counts, host load.
# bhatti admin metrics server only
Query metrics snapshots over time.
## Synopsis
```
sudo bhatti admin metrics [--since ]
```
## Description
The server takes a metrics snapshot roughly every minute and persists it to the database (in `metrics_snapshots`). This command reads them back and prints a compact table — request rates, error counts, proxy traffic, cold wakes, sandbox state distribution, host load, available memory.
The output is **bucketed automatically** based on the time range:
\| Range | Bucket size | | ----- | ----------- | | < 2 hours | 1 minute | | 2–24 hours | 15 minutes | | > 24 hours | 1 hour |
Each row aggregates the snapshots in its bucket (sums for counters like `req/min`, last-value for gauges like `LOAD`).
`--since` accepts the same formats as [`admin events --since`](/docs/reference/cli/admin/admin-events/) — relative durations or absolute dates. The default is **1 hour**.
## Examples
```bash
# Last hour at 1-minute resolution (default)
sudo bhatti admin metrics
```
```text
TIME REQ/min ERR PROXY/min WAKES HOT WARM COLD LOAD MEM_AVAIL
14:21 47 0 8 0 4 6 7 0.4 19421 MB
14:20 52 1 9 1 4 5 8 0.5 19388 MB
14:19 44 0 7 0 4 4 9 0.5 19412 MB
...
```
```bash
# Last 12 hours, auto-bucketed to 15-minute intervals
sudo bhatti admin metrics --since 12h
```
```bash
# Last week, auto-bucketed to 1-hour intervals
sudo bhatti admin metrics --since 7d
```
```bash
# Machine-readable, raw 1-min snapshots for the last 30 minutes
sudo bhatti admin metrics --since 30m --json | jq '.[] | .api_requests'
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--since ` | `1h` | Earliest snapshot to include. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti admin status`](/docs/reference/cli/admin/admin-status/) — current totals
* [`bhatti admin events`](/docs/reference/cli/admin/admin-events/) — what happened, when
# bhatti admin status
> One-shot system overview — sandboxes, users, host load, recent events.
# bhatti admin status server only
One-shot system overview.
## Synopsis
```
sudo bhatti admin status
```
## Description
Reads the latest metrics snapshot and event log from the local SQLite database and prints a compact dashboard:
* **Version + update notice** — current server version, latest GitHub release if newer.
* **Host stats** — load average, memory in use vs. total.
* **Sandbox totals** — broken down by thermal state (hot/warm/cold).
* **User totals** — total users, currently-active users.
* **API + proxy counters** — total requests, errors, auth failures, cold-wake count.
* **Per-user breakdown** — sandbox count vs. limit, current running/stopped split.
* **Recent events** — last \~10 events from the audit log with a one-line summary each.
Reads database state directly; doesn't touch the engine. Safe to run while the daemon is busy.
## Examples
```bash
sudo bhatti admin status
```
```text
Bhatti v1.10.0
Host: 0.42 load, 12.4 / 32.0 GB memory
Sandboxes 17 total (4 hot, 6 warm, 7 cold)
Users 8 total, 5 active
API 184.2K requests, 423 errors, 12 auth failures
Proxy 47.1K requests, 89 errors, 156 cold wakes
USER SANDBOXES STATUS
alice 3/10 2 running, 1 stopped
bob 0/5 -
worker-bot 5/5 5 running
RECENT EVENTS
14:22:18 thermal.pause alice scraper idle=180s
14:21:45 sandbox.created bob test-1 cpus=2 mem=1024 image=minimal
14:20:11 proxy.error - staging alias=staging status=502
14:18:33 thermal.wake alice api from=cold 47ms
...
```
```bash
# Machine-readable
sudo bhatti admin status --json | jq .gauges.sandboxes_hot
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti admin events`](/docs/reference/cli/admin/admin-events/) — drill into the event log
* [`bhatti admin metrics`](/docs/reference/cli/admin/admin-metrics/) — time-series view
# bhatti completion
> Generate shell completion scripts for bash, zsh, or fish.
Generate a shell completion script.
## Synopsis
```
bhatti completion
```
## Description
Prints the completion script for the requested shell to stdout. Save it to the right place for your shell, restart, and tab-completion is enabled.
**Sandbox-name completion** uses a local cache (`/tmp/bhatti-completions-`), populated by `create`, `destroy`, and `list`. It never hits the network, so completions are instant and work offline. The cache may be slightly stale right after another machine creates a sandbox; running `bhatti list` refreshes it.
## Examples
### bash
```bash
# System-wide
bhatti completion bash | sudo tee /etc/bash_completion.d/bhatti > /dev/null
# Or per-user (no sudo)
echo 'source <(bhatti completion bash)' >> ~/.bashrc
```
### zsh
```bash
bhatti completion zsh > "${fpath[1]}/_bhatti"
# Or simpler
echo 'source <(bhatti completion zsh)' >> ~/.zshrc
```
### fish
```bash
bhatti completion fish > ~/.config/fish/completions/bhatti.fish
```
## Options
This command takes no flags besides the positional shell argument.
## See also
* [`bhatti setup`](/docs/reference/cli/admin/setup/) — `setup` detects your shell and prints the right snippet automatically.
# bhatti serve
> Start the bhatti daemon on Linux (KVM) or macOS (HVF).
# bhatti serve server only
Start the bhatti daemon.
## Synopsis
```
sudo bhatti serve
```
## Description
Starts the HTTP API listener, the thermal manager, the public proxy (if domain mode or `public_proxy_listen` is configured), the port scanner, and the metrics + event recorders. Recovers any previously running sandboxes from the SQLite database on startup, adopting their live `bhatti-vmm` helpers by PID.
Requires:
* Linux with KVM (`/dev/kvm`), or macOS on Apple Silicon (HVF)
* Root on Linux (for `/dev/kvm`); macOS needs no root
* Config at `/etc/bhatti/config.yaml` (or `$BHATTI_CONFIG`)
* The runtime bundle (`bhatti-vmm`, `bhatti-netd`, `libkrun`, the lean kernel) and at least one rootfs image present at the paths in the config
Logs are emitted as structured JSON to stdout. Set `BHATTI_LOG_LEVEL=debug` for verbose logging. Most operators run this under systemd; the install script sets up the unit file at `/etc/systemd/system/bhatti.service`.
## Examples
```bash
# Foreground (development)
sudo bhatti serve
```
```bash
# Under systemd (production)
sudo systemctl start bhatti
sudo journalctl -u bhatti -f
```
```bash
# Verbose logs
BHATTI_LOG_LEVEL=debug sudo bhatti serve
```
## Options
This command takes no flags. Configuration is via `/etc/bhatti/config.yaml`. See [Configuration](/docs/reference/config/).
## Lifecycle
* **Startup:** load config → open store → initialise engine → recover VMs → start servers (API + optional public proxy + optional internal loopback).
* **Shutdown** (`SIGTERM` / `SIGINT`): drain HTTP, snapshot all running VMs to disk, close store, exit. Snapshots are restored on the next start.
## See also
* [Configuration](/docs/reference/config/)
* [Self-hosting](/docs/self-hosting/)
# bhatti setup
> Configure the CLI's API endpoint and key. Saves to ~/.bhatti/config.yaml. Interactive by default; --url and --token make it scriptable.
Configure the CLI's API endpoint and key.
## Synopsis
```
bhatti setup [--url URL] [--token KEY]
```
## Description
Writes the API endpoint and key to `~/.bhatti/config.yaml` and tests the connection by listing sandboxes. The auth test hits `GET /sandboxes` (an authenticated endpoint), so a bad key fails immediately instead of appearing to succeed.
With no flags, runs interactively — prompts for endpoint and key, masks the key as you type. With `--url` and `--token`, runs non-interactively. Use the latter form for agents, CI scripts, and provisioning tools.
You usually don't need to run this on the server itself: the install script (`curl -fsSL bhatti.sh/install | sudo bash`) creates the admin user and writes the config to your `$SUDO_USER`'s `~/.bhatti/config.yaml` automatically. Run `setup` on a different machine that you want to drive the server from, or to switch your local CLI to a different bhatti server.
## Examples
### Interactive (the default)
```bash
bhatti setup
```
```text
API endpoint [http://localhost:8080]: https://api.bhatti.sh
API key:
Saved to /home/alice/.bhatti/config.yaml
Testing connection... ✓ authenticated (3 sandboxes)
Enable completions:
echo 'source <(bhatti completion zsh)' >> ~/.zshrc
```
### Non-interactive (agents, CI, provisioners)
```bash
bhatti setup --url https://api.bhatti.sh --token bht_abc123
```
```text
Saved to /home/alice/.bhatti/config.yaml
Testing connection... ✓ authenticated (3 sandboxes)
```
The completions hint is suppressed — there's no human reading the output anyway. If the connection test fails, the command exits non-zero so your provisioner can detect the failure.
## Options
``string required
API endpoint URL. Sets the prompt's default. With `--token` set too, the prompt is skipped entirely and `setup` writes immediately.
``string required
API key. With `--url` set too, the prompt is skipped and `setup` writes immediately.
The auth test always runs. If you need to write the config without contacting a server (pre-baking an image, for example), edit `~/.bhatti/config.yaml` directly — it's just two YAML keys.
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token` (the global versions of these — they override config but don't write to it; `setup`'s flags are the persisting form), `--json`, `--timing`, `--data-dir`.
## Configuration file
After `setup`, your config looks like:
```yaml
api_url: https://api.bhatti.sh
auth_token: bht_abc123def456...
```
At runtime, this can be overridden with `--url`, `--token`, `BHATTI_URL`, or `BHATTI_TOKEN`. See [Configuration precedence](/docs/reference/cli/#configuration-precedence).
## See also
* [`bhatti version`](/docs/reference/cli/admin/version/) — check connection without changing config
* [`bhatti user create`](/docs/reference/cli/admin/user-create/) — create a user and get an API key (server-side, run by the operator)
* [`bhatti completion`](/docs/reference/cli/admin/completion/) — shell completions
* [Configuration](/docs/reference/config/) — full file format
* [Self-hosting → adding teammates](/docs/self-hosting/#adding-teammates) — the full flow for setting someone up against a remote bhatti
# bhatti update
> Update bhatti. Auto-detects CLI vs server.
Update bhatti to the latest release.
## Synopsis
```
bhatti update # auto-detect: CLI on a client, full update on a server
sudo bhatti update # full server update (CLI, kernel, lohar, rootfs)
sudo bhatti update --tiers all # also pull additional rootfs tiers
bhatti update --cli-only # update just the CLI binary, even on a server
```
## Description
Downloads `https://bhatti.sh/install` and runs it. The installer auto-detects whether this is a server (config exists at `/etc/bhatti/config.yaml`) or a CLI-only host, and updates accordingly.
**On a CLI-only machine:** updates the `bhatti` binary at `/usr/local/bin/bhatti`. Doesn't need root unless `/usr/local/bin` isn't writable by the user.
**On a server:** updates everything — the CLI plus the runtime bundle (`bhatti-vmm`, `bhatti-netd`, `libkrun`, the lean kernel) and any installed rootfs tier images. Only changed components are re-downloaded; unchanged tiers and kernels are skipped.
**`--cli-only`** forces the binary-only update path even on a server. Useful when you want to test a CLI fix without touching kernel/rootfs.
**`--tiers`** takes a comma-separated list (or `all`) of additional rootfs tiers to install (`browser`, `docker`, `computer`). The default install only ships `minimal`; this is how you add more without re-running the full install command.
Server updates require root (`/var/lib/bhatti` and the runtime bundle are root-owned). The command refuses up front if it detects a server install but the user isn't root, rather than partly-completing then failing inside the install script.
## Examples
```bash
# CLI host
bhatti update
```
```bash
# Server, default behaviour (full update)
sudo bhatti update
```
```bash
# Server, also pull all tiers
sudo bhatti update --tiers all
```
```bash
# Server, only update the CLI binary
bhatti update --cli-only
```
```bash
# Server, add a single tier
sudo bhatti update --tiers browser
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--cli-only` | `false` | Update only the CLI binary, even on a server. | | `--tiers ` | — | Comma-separated rootfs tiers to install, or `all`. Server-only — has no effect on CLI hosts. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti version`](/docs/reference/cli/admin/version/) — check current version + latest release
* [Self-hosting](/docs/self-hosting/) — initial install
* [Updating](/docs/updating/) — upgrade and uninstall guide
# bhatti user create
> Create a user with API key and per-user resource limits.
# bhatti user create server only
Create a user with an API key and per-user resource limits.
## Synopsis
```
sudo bhatti user create --name
[--max-sandboxes ] [--max-cpus ] [--max-memory ]
```
## Description
Allocates a new user record in the local SQLite database. The install script already runs this once for `admin` during setup, so the first time you'd call `user create` yourself is when you want to add a teammate or a CI account.
Each user gets:
* A user ID (`usr_<8-hex>`).
* An **API key** (`bht_<64-hex>`) — printed once, then only its SHA-256 hash is stored. **Save the key immediately**; it can't be recovered. Use [`user rotate-key`](/docs/reference/cli/admin/user-rotate-key/) if it's lost.
* A **subnet index** — an integer assigned monotonically. It's a vestigial field carried over from v1; in v2, user isolation comes from API scoping and a per-owner `bhatti-netd` gateway, not a per-user bridge network. You don't need to think about it.
* Per-sandbox resource caps (`--max-cpus`, `--max-memory`) and a per-user sandbox count cap (`--max-sandboxes`).
Records the `user.created` event in the audit log.
This is a **server-only** command — it operates directly on `/var/lib/bhatti/state.db`. Runs without the daemon needing to be up, but typically as `sudo` because the data dir is root-owned.
## Examples
```bash
sudo bhatti user create --name alice
```
```text
User created:
ID: usr_a1b2c3d4
Name: alice
Subnet: 2
API key: bht_abc123def456...
This key will not be shown again. Save it now.
For remote CLI use:
bhatti setup --url https://your-server:8080 --token bht_abc123def456...
```
```bash
# Custom limits
sudo bhatti user create --name beefy \
--max-sandboxes 20 \
--max-cpus 8 \
--max-memory 16384
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--name ` | (required) | Username. Must be unique. | | `--max-sandboxes ` | `5` | Maximum concurrent sandboxes. | | `--max-cpus ` | `4` | Maximum vCPUs per sandbox (capped at create time). | | `--max-memory ` | `4096` | Maximum memory MB per sandbox (capped at create time). |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
`--data-dir` is honoured here when you need to point at a non-default data directory.
## See also
* [`bhatti user list`](/docs/reference/cli/admin/user-list/)
* [`bhatti user rotate-key`](/docs/reference/cli/admin/user-rotate-key/) — replace a lost or compromised key
* [Users & auth](/docs/managing/users/) — broader narrative on multi-tenant setup
# bhatti user delete
> Delete a user. Fails if the user has active sandboxes.
# bhatti user delete server only
Delete a user.
## Synopsis
```
sudo bhatti user delete [-y]
```
## Description
Removes the user record. The user's API key stops working immediately.
**Fails if the user has active (non-destroyed) sandboxes.** Destroy them first:
```bash
sudo bhatti user list # find them via the user's sandbox count
# (server-side — to list a specific user's sandboxes you need the SQL or the user's API key)
```
Volumes, secrets, snapshots, and images owned by the user remain on disk after the user is deleted (orphaned). A future `--purge` mode could clean these up; for now, manual cleanup is required if you want to reclaim the space.
By default, prompts for confirmation. In a non-interactive shell without `-y`, the command refuses and exits `1`.
Records a `user.deleted` event in the audit log.
## Examples
```bash
sudo bhatti user delete alice
```
```text
Delete user "alice"? [y/N]: y
deleted user "alice"
```
```bash
# Active sandboxes still around
sudo bhatti user delete alice
```
```text
Error: user "alice" has 3 active sandbox(es)
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `-y`, `--yes` | `false` | Skip confirmation. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti user list`](/docs/reference/cli/admin/user-list/)
* [`bhatti user rotate-key`](/docs/reference/cli/admin/user-rotate-key/) — usually a better answer than delete
# bhatti user list
> List users with their resource limits and current sandbox counts.
# bhatti user list server only
List all users on the server.
## Synopsis
```
sudo bhatti user list
```
## Description
Lists all users with ID, name, current sandbox count vs. limit, per-sandbox CPU and memory caps, and the subnet index. Reads from the local SQLite database.
## Examples
```bash
sudo bhatti user list
```
```text
ID NAME SANDBOXES CPUS MEM SUBNET
usr_a1b2c3d4 alice 3/10 4 4096 2
usr_e5f6g7h8 bob 0/5 4 4096 3
usr_i9j0k1l2 worker-bot 5/5 2 2048 4
```
The `SANDBOXES` column is `current/limit` — `5/5` means at-cap. The `SUBNET` column is a vestigial per-user index carried over from v1 — in v2 it no longer maps to a bridge network (isolation is API scoping + the per-owner `bhatti-netd` gateway).
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti user create`](/docs/reference/cli/admin/user-create/)
* [`bhatti admin status`](/docs/reference/cli/admin/admin-status/) — also shows per-user sandbox breakdown
# bhatti user rotate-key
> Rotate a user's API key. Old key is immediately invalidated.
# bhatti user rotate-key server only
Rotate a user's API key.
## Synopsis
```
sudo bhatti user rotate-key
```
## Description
Generates a new API key, stores its SHA-256 hash, and prints the plaintext **once**. The previous key is invalidated server-side immediately — any in-flight requests using the old key fail with `401`.
Use this when:
* A key may have leaked.
* A user has lost their key (the original is unrecoverable).
* You have a key-rotation policy.
The user's existing sandboxes, secrets, volumes, snapshots, and images are all preserved — only the auth token changes.
Records a `user.key_rotated` event in the audit log.
## Examples
```bash
sudo bhatti user rotate-key alice
```
```text
API key rotated for "alice"
New key: bht_xyz789...
The old key is immediately invalidated.
This key will not be shown again. Save it now.
```
After rotation, give the user the new key. They run `bhatti setup` to update their config.
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti user create`](/docs/reference/cli/admin/user-create/)
* [`bhatti setup`](/docs/reference/cli/admin/setup/) — what the user runs to install the new key
# bhatti version
> Print CLI version, server version, and check for updates.
Print CLI and server versions; check for updates.
## Synopsis
```
bhatti version
```
## Description
Prints:
* **CLI version** — embedded at build time via `-ldflags`. Shows `dev` for unreleased builds.
* **API endpoint** — currently configured from flags, env, or config.
* **Server version** — read from the `X-Bhatti-Version` response header on a probe to `GET /sandboxes`. Skipped if unreachable or unauthenticated.
* **Update notice** — if the CLI's GitHub release check (cached for 1 hour at `~/.bhatti/.latest-version`) finds a newer release, prints it.
The server-version probe also drives the `X-Bhatti-Min-CLI` warning that appears on every command — if your CLI is older than the server's minimum, every command emits a one-line stderr warning.
## Examples
```bash
bhatti version
```
```text
bhatti v1.10.0
api: https://api.bhatti.sh
server: v1.10.0
```
```bash
# When an update is available
bhatti version
```
```text
bhatti v1.9.0
api: https://api.bhatti.sh
server: v1.10.0
Update available: v1.9.0 → v1.10.0 (bhatti update)
```
```bash
# Machine-readable
bhatti version --json
```
```json
{
"version": "v1.10.0",
"api": "https://api.bhatti.sh",
"server_version": "v1.10.0",
"latest_version": "v1.10.0"
}
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti update`](/docs/reference/cli/admin/update/) — apply available updates
* [`bhatti admin status`](/docs/reference/cli/admin/admin-status/) — also prints version info on the server side
# Execution & shells
> Run commands, open shells, list active sessions, and discover listening ports inside sandboxes.
Commands that drive execution inside a running sandbox: one-shot commands, interactive shells, and visibility into what’s currently running and listening.
| Command | Description |
| ------------------------------------------------- | ------------------------------------------------------------------------------- |
| [`bhatti exec`](/docs/reference/cli/exec/exec/) | Run a command. Streaming or buffered output, optional timeout, optional detach. |
| [`bhatti shell`](/docs/reference/cli/exec/shell/) | Open an interactive shell with detach/reattach semantics (alias: `sh`). |
| [`bhatti ps`](/docs/reference/cli/exec/ps/) | List active sessions in a sandbox (init scripts, shells, detached execs). |
| [`bhatti ports`](/docs/reference/cli/exec/ports/) | List listening ports detected inside a sandbox. |
All commands wake cold sandboxes transparently — you don’t need to start the sandbox first.
## Quick patterns
[Section titled “Quick patterns”](#quick-patterns)
```bash
bhatti exec dev -- npm install # one-shot command
bhatti exec dev -- bash -c 'echo $API_KEY' # shell expansion via /bin/bash
bhatti exec dev --timeout 60 -- long-running.sh # cap runtime at 60s
bhatti exec dev --detach -- ./worker.sh # run in background, return PID
bhatti shell dev # interactive (Ctrl+\ to detach)
bhatti shell dev --new # don't reattach to a running session
bhatti ps dev # what's running inside?
bhatti ports dev # what's listening?
```
## See also
[Section titled “See also”](#see-also)
* [Sandbox commands](/docs/reference/cli/sandbox/) — create/list/destroy
* [Files](/docs/reference/cli/files/) — read and write files inside sandboxes
* [API: `POST /sandboxes/:id/exec`](/docs/reference/api/#run-a-command) — the underlying HTTP endpoint, streaming and detach modes
# bhatti exec
> Execute a command inside a sandbox. Streaming or buffered output, optional timeout, optional detach.
Execute a command inside a sandbox. The exit code of the child process is forwarded to the CLI's exit code. Sleeping sandboxes wake automatically.
## Synopsis
```
bhatti exec [--] [flags]
```
## Description
The `--` separator is optional — `bhatti exec dev echo hello` works just like `bhatti exec dev -- echo hello`. Use `--` only when the command contains flags that cobra would otherwise try to parse (e.g. `bhatti exec dev -- ls -la /tmp`).
Commands run as the unprivileged user `lohar` (uid 1000), not root. Use `sudo` inside the sandbox for root operations. Stdout and stderr are kept on separate streams; when output is captured, the result distinguishes them.
**Output mode auto-detects:**
* **Stdout is a TTY** → streaming NDJSON, line-by-line, flushed as the process emits it. You see `npm install`'s progress bar in real time.
* **Stdout is a pipe or `--json` is set** → buffered. The CLI prints a JSON object `{exit_code, stdout, stderr}` once the command finishes.
* **`BHATTI_FORCE_STREAM=1`** → forces streaming even when piping. Useful for CI logs that want progressive output.
**Cold sandboxes wake transparently.** If the sandbox is `cold` or `warm`, the server resumes it before executing — typically \~50ms for cold, sub-millisecond for warm. The wake is invisible to the caller; the command just takes a bit longer.
**Failures get recovery hints.** Common errors (sandbox not found, sandbox not running, name conflict, quota reached) are followed by a one-line suggestion in stderr. For example:
```text
404 Not Found: sandbox not found
Check sandbox name:
bhatti ls
```
## Examples
```bash
# Most basic
bhatti exec dev -- echo hello
```
```bash
# -- is optional
bhatti exec dev echo hello
```
```bash
# Pipe output to other tools — CLI switches to buffered mode automatically
bhatti exec dev -- cat /workspace/data.json | jq .name
```
```bash
# Shell expansion needs a shell — env vars in the *guest*, not in your terminal
bhatti exec dev -- bash -c 'echo $API_KEY'
```
```bash
# Cap a long-running command at 60s
bhatti exec dev --timeout 60 -- ./benchmark.sh
```
```bash
# Run in the background, return immediately with the guest PID
bhatti exec dev --detach -- ./worker.sh
```
```text
pid: 4218
output: /var/log/lohar/sessions/sess-7b3c.log
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--timeout ` | `300` | Exec timeout in seconds. The server kills the process group on timeout and returns exit code `137`. Hard max: `86400` (24 hours). | | `--detach` | `false` | Run the command in the background and return immediately with the guest PID and the path to the captured output file. The session continues until it finishes or is killed. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## Exit codes
\| Code | Meaning | | ---- | ------- | | `` | The child process's exit code is forwarded verbatim. | | `137` | The child was killed (timeout exceeded, or session manually killed). | | `1` | CLI / API error before the command ran (sandbox not found, network, auth). |
## See also
* [`bhatti shell`](/docs/reference/cli/exec/shell/) — interactive shell with reattach
* [`bhatti ps`](/docs/reference/cli/exec/ps/) — see detached / running sessions
* [API: `POST /sandboxes/:id/exec`](/docs/reference/api/#run-a-command)
# bhatti ports
> List listening TCP ports detected inside a sandbox, with proxy URLs for each.
List the listening TCP ports detected inside a sandbox.
## Synopsis
```
bhatti ports
```
## Description
The host periodically scans each running sandbox for listening ports and caches them. `bhatti ports` reads that cache and prints one row per port. Each row also shows the path-based proxy URL — a developer convenience for hitting the port from the host without a domain or `bhatti publish`.
The proxy URL goes through the API server at `/sandboxes//proxy//...`. It requires the same Bearer auth as any other API call — useful for poking at services during development. For public, auth-free URLs, use [`bhatti publish`](/docs/reference/cli/networking/publish/).
Cold sandboxes are not scanned (they have no listening processes by definition). To list ports for a sleeping sandbox, wake it first or just send a request that triggers a wake.
## Examples
```bash
bhatti ports dev
```
```text
PORT URL
3000 http://localhost:8080/sandboxes/dev/proxy/3000/
6379 http://localhost:8080/sandboxes/dev/proxy/6379/
```
```bash
# Quick test against a service inside the sandbox
bhatti ports dev --json | jq -r '.[] | .proxy_url' | head -1 | xargs curl -H "Authorization: Bearer $BHATTI_TOKEN"
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti publish`](/docs/reference/cli/networking/publish/) — public auth-free URL for a port
* [API: `GET /sandboxes/:id/ports`](/docs/reference/api/#list-listening-ports)
* [API: `ANY /sandboxes/:id/proxy/:port/*`](/docs/reference/api/#reverse-proxy)
# bhatti ps
> List active sessions inside a sandbox — init scripts, shells, detached execs.
List active sessions inside a sandbox.
## Synopsis
```
bhatti ps
```
## Description
A *session* is a process the guest agent is tracking — typically because it has a PTY, was started detached, or was launched as the sandbox's `--init` script. Active sessions persist across client disconnections and show up here regardless of whether anyone is attached.
The `init` script (from `bhatti create --init "..."`) appears as a session named `init`. You can attach to it with [`bhatti shell`](/docs/reference/cli/exec/shell/) (passing its session ID via the API) to watch progress.
Sessions without an attached client and without recent I/O are eventually garbage-collected by an idle timer. Active sessions (producing output or accepting input) are never killed by the timer.
## Examples
```bash
bhatti ps dev
```
```text
ID COMMAND RUNNING ATTACHED
init npm install && npm run dev true false
s1 /bin/zsh -li true true
s2 ./long-running-worker.sh true false
```
```bash
bhatti ps dev --json | jq '.[] | select(.attached == false) | .session_id'
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti shell`](/docs/reference/cli/exec/shell/) — open or reattach to an interactive session
* [`bhatti exec --detach`](/docs/reference/cli/exec/exec/) — start a session in the background
* [API: `GET /sandboxes/:id/sessions`](/docs/reference/api/#list-sessions)
# bhatti shell
> Open an interactive shell inside a sandbox. Detach with Ctrl+\, reattach later, scrollback preserved.
Open an interactive terminal inside a sandbox. Aliased as `bhatti sh`.
## Synopsis
```
bhatti shell [--new]
bhatti sh
```
## Description
Opens a WebSocket connection to the sandbox's guest agent and attaches a PTY session. The default shell is `/bin/zsh`. Terminal resize is forwarded to the guest.
**Detach with `Ctrl+\`.** The shell keeps running; output flows into a 64 KB scrollback ring buffer in the guest. Reconnect with `bhatti shell ` and you'll see the recent scrollback followed by live I/O. Detach does **not** send `SIGHUP` — child processes survive.
**Single attached client per session.** If you reattach from a second machine, the first client is disconnected. Use `--new` to force a brand-new session instead of reattaching.
**Clean exit vs. lost connection.** When the shell exits cleanly (Ctrl+D, `exit`), the CLI returns silently. Any other disconnection (network drop, server restart) shows:
```text
connection lost (session s3f9 still running)
reconnect: bhatti shell dev
```
The session keeps running in the guest. Reattach with the same command to pick it back up.
## Examples
```bash
bhatti shell dev
```
```bash
# Force a new session, even if one is already attached
bhatti shell dev --new
```
```bash
# Detach scenario:
bhatti shell dev # attach, run things, hit Ctrl+\
# ...session keeps running in the guest...
bhatti shell dev # reattach later, scrollback replayed
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--new` | `false` | Don't reattach to an existing session. Start a fresh one. The previous session, if any, is left running. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti exec`](/docs/reference/cli/exec/exec/) — non-interactive command execution
* [`bhatti ps`](/docs/reference/cli/exec/ps/) — list active sessions including this shell
* [`bhatti share`](/docs/reference/cli/networking/share/) — browser-based shell URL
* [API: `GET /sandboxes/:id/ws`](/docs/reference/api/#interactive-shell-websocket)
# File operations
> Read, write, and list files inside sandboxes.
Move bytes in and out of a sandbox without opening a shell. Useful for scripted setup, fetching results, and inspecting state.
| Command | Description |
| ------------------------------------------------------- | ----------------------------------------------- |
| [`bhatti file read`](/docs/reference/cli/files/read/) | Read a file from a sandbox to stdout. |
| [`bhatti file write`](/docs/reference/cli/files/write/) | Write a file from stdin into a sandbox. Atomic. |
| [`bhatti file ls`](/docs/reference/cli/files/ls/) | List directory contents inside a sandbox. |
The CLI exposes the simplest forms — read whole files, write whole files. The underlying API supports partial reads (`offset`, `limit`, `max_bytes`), permission control (`mode`), and HEAD-style stat requests; reach for it directly when you need those. See the [API reference](/docs/reference/api/#files) for the full surface.
## Quick patterns
[Section titled “Quick patterns”](#quick-patterns)
```bash
bhatti file read dev /workspace/app.js
echo 'config' | bhatti file write dev /etc/config
bhatti file ls dev /workspace/
bhatti file read dev /workspace/data.json | jq .
```
## Limits
[Section titled “Limits”](#limits)
* Writes are capped at **100 MB** per request.
* Directory listings are capped at **10 000 entries**; a sentinel entry indicates truncation.
* Writes are atomic — temp file, fsync, rename. Concurrent readers never see a partial file.
# bhatti file ls
> List directory contents inside a sandbox.
List directory contents inside a sandbox.
## Synopsis
```
bhatti file ls
```
## Description
Lists files and subdirectories at the given absolute path. The default human format prints one row per entry: a directory flag, mode, size in bytes, and name.
Listings are capped at **10 000 entries**. If the directory has more entries than that, a sentinel row indicates truncation. For listings with this many entries you almost certainly want to be running `find` inside the sandbox via [`bhatti exec`](/docs/reference/cli/exec/exec/) and processing the results there instead.
## Examples
```bash
bhatti file ls dev /workspace
```
```text
drwxr-xr-x 4096 node_modules
-rw-r--r-- 1234 app.js
-rw-r--r-- 256 package.json
-rw-r--r-- 512 README.md
```
```bash
# JSON for scripting
bhatti file ls dev /workspace --json | jq '.[] | select(.is_dir == false) | .name'
```
## Output fields (JSON)
```json
[
{"name": "app.js", "size": 1234, "mode": "0644", "is_dir": false, "mtime": 1761792345},
{"name": "node_modules", "size": 4096, "mode": "0755", "is_dir": true, "mtime": 1761792500}
]
```
`mtime` is a Unix timestamp in seconds.
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti file read`](/docs/reference/cli/files/read/) / [`bhatti file write`](/docs/reference/cli/files/write/)
* [API: `GET /sandboxes/:id/files?ls=true`](/docs/reference/api/#list-directory)
# bhatti file read
> Read a file from a sandbox to stdout.
Read a file from a sandbox and write its bytes to stdout.
## Synopsis
```
bhatti file read
```
## Description
Streams the file's contents to stdout, byte-for-byte. The path must be absolute. Cold sandboxes wake transparently before the read.
The CLI always reads the whole file. The API supports partial reads (`offset`, `limit`, `max_bytes` query params) for tailing logs without transferring the entire file — call it directly when you need that. Every response includes an `X-File-Size` header so clients can detect truncation. See [API: files](/docs/reference/api/#files).
## Examples
```bash
bhatti file read dev /workspace/app.js
```
```bash
# Pipe to other tools
bhatti file read dev /workspace/data.json | jq .name
```
```bash
# Save to a local file
bhatti file read dev /var/log/agent.log > agent-$(date +%F).log
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti file write`](/docs/reference/cli/files/write/) — opposite direction
* [`bhatti file ls`](/docs/reference/cli/files/ls/) — list a directory
* [API: `GET /sandboxes/:id/files`](/docs/reference/api/#read-a-file)
# bhatti file write
> Write a file into a sandbox from stdin. Atomic, capped at 100 MB.
Write a file into a sandbox. Reads bytes from stdin and writes them to the absolute `` inside the sandbox.
## Synopsis
```
bhatti file write
```
## Description
Writes are atomic — the guest agent writes to a temp file, fsyncs it, then renames it over the target. Concurrent readers never see partial content.
Writes are capped at **100 MB** per request. For larger files, split into chunks or attach a [persistent volume](/docs/reference/cli/volumes/) instead.
The CLI doesn't expose `--mode` for setting the file permissions (defaults to `0644`). The underlying [API endpoint](/docs/reference/api/#write-a-file) accepts a `mode=0755` query parameter — use it directly when you need to set a specific mode (e.g. `0700` for keys, `0755` for executables).
## Examples
```bash
echo 'console.log("hello")' | bhatti file write dev /workspace/app.js
```
```bash
# From a local file
bhatti file write dev /workspace/config.json < ./config.json
```
```bash
# Generate and write inline
date | bhatti file write dev /tmp/last-deploy.txt
```
```bash
# Set executable bit via the API directly
local_token="$BHATTI_TOKEN"
curl -X PUT "http://localhost:8080/sandboxes/dev/files?path=/usr/local/bin/run.sh&mode=0755" \
-H "Authorization: Bearer $local_token" \
--data-binary @./run.sh
```
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti file read`](/docs/reference/cli/files/read/) — opposite direction
* [`bhatti create --file local:guest`](/docs/reference/cli/sandbox/create/) — inject a file at boot, no separate write call
* [API: `PUT /sandboxes/:id/files`](/docs/reference/api/#write-a-file)
# Images
> Manage rootfs images. Pull from public OCI registries, import from Docker, save sandboxes as reusable images.
Images are ext4 filesystem snapshots used as sandbox root filesystems. bhatti ships pre-built tier images (`minimal`, `browser`, `docker`, `computer`); you can also pull from public OCI registries, import from Docker, or save a running sandbox’s filesystem as an image.
| Command | Description |
| ------------------------------------------------------------- | ---------------------------------------------------------- |
| [`bhatti image list`](/docs/reference/cli/images/list/) | List available images. |
| [`bhatti image pull`](/docs/reference/cli/images/pull/) | Pull from a public OCI registry. Async; returns a task ID. |
| [`bhatti image import`](/docs/reference/cli/images/import/) | Import a local Docker image, or a tarball with `--tar`. |
| [`bhatti image save`](/docs/reference/cli/images/save/) | Save a running sandbox’s rootfs as a reusable image. |
| [`bhatti image delete`](/docs/reference/cli/images/delete/) | Delete an image. |
| [`bhatti image share`](/docs/reference/cli/images/share/) | Share an image with specific users (server-only). |
| [`bhatti image unshare`](/docs/reference/cli/images/unshare/) | Revoke image access from users (server-only). |
## Built-in tiers
[Section titled “Built-in tiers”](#built-in-tiers)
| Tier | What’s in it | Size |
| ---------- | --------------------------------- | -------- |
| `minimal` | Bare Ubuntu 24.04 + curl + fuse3 | \~200 MB |
| `browser` | + Chromium, Playwright, Node 22 | \~600 MB |
| `docker` | + Docker Engine | \~550 MB |
| `computer` | + XFCE desktop, KasmVNC, Chromium | \~1.5 GB |
```bash
bhatti create --name scraper --image browser
bhatti create --name ci --image docker
```
The server auto-discovers tiers from `/var/lib/bhatti/images/`. Install more with `sudo bhatti update --tiers all`.
## Image scoping
[Section titled “Image scoping”](#image-scoping)
Each image is owned by the user who created it (or by the system, for built-in tiers). Other users can’t see or use your custom images by default.
To share with a specific user: [`sudo bhatti image share --user alice`](/docs/reference/cli/images/share/). The command operates directly on the local SQLite database, so it requires running on the server with DB access — it isn’t an API call you can make remotely.
Use `--list` to inspect current shares; pass `--user alice --user bob` to share with multiple users in one call. [`unshare`](/docs/reference/cli/images/unshare/) revokes access by user.
# bhatti image delete
> Delete an image. Cannot be undone.
Delete an image.
## Synopsis
```
bhatti image delete [-y]
```
## Description
Removes the image record and the underlying ext4 file. **Cannot be undone.**
Sandboxes that were created from this image keep running — they have their own copy-on-write rootfs derived from the image at creation time. They don't reference the original after boot.
By default, `delete` prompts for confirmation. In a non-interactive shell without `-y`, the command refuses and exits `1`.
You cannot delete a system tier image (`minimal`, `browser`, etc.) — those are owned by `user_id=""` and only the operator can remove them by removing the file from `/var/lib/bhatti/images/`.
## Examples
```bash
bhatti image delete python-3.12
```
```text
Delete image "python-3.12"? [y/N]: y
deleted
```
```bash
# Non-interactive
bhatti image delete python-3.12 -y
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `-y`, `--yes` | `false` | Skip confirmation. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti image list`](/docs/reference/cli/images/list/)
* [API: `DELETE /images/:name`](/docs/reference/api/#delete-an-image)
# bhatti image import
> Import a local Docker image (via docker save) or a raw tarball.
Import an image from a local Docker daemon, or directly from a tarball without Docker.
## Synopsis
```
bhatti image import [--name ]
bhatti image import --tar --name
```
## Description
Two modes:
**Docker mode** (default). The CLI runs `docker save ` on your machine and streams the resulting tarball to the bhatti server, which converts it to an ext4 rootfs. Requires Docker installed locally. The image must already be in your local Docker daemon — pull it first with `docker pull` if needed.
**Tarball mode** (`--tar`). Skip Docker entirely. The CLI reads the file at `` and streams it to the server. The tarball must be in the format produced by `docker save` (an OCI layout or v1 image manifest). `--name` is required in this mode since there's no ref to derive the name from.
If you don't pass `--name` in Docker mode, a name is derived from the ref the same way as `image pull`: `python:3.12` → `python-3.12`.
For private registries, this is the recommended path — `docker pull` handles authentication on your machine using your existing Docker credentials, then bhatti just receives the bytes:
```bash
docker pull ghcr.io/org/private:latest
bhatti image import ghcr.io/org/private:latest
```
## Examples
```bash
# Import from local Docker (name derived from ref)
bhatti image import python:3.12
```
```bash
# Private image: pull with Docker first, then import
docker pull ghcr.io/org/private:latest
bhatti image import ghcr.io/org/private:latest
```
```bash
# Locally-built image
docker build -t my-env .
bhatti image import my-env
```
```bash
# Custom name override
bhatti image import python:3.12 --name py312
```
```bash
# Raw tarball — no Docker needed
docker save ubuntu:24.04 -o /tmp/ubuntu.tar
bhatti image import --tar /tmp/ubuntu.tar --name ubuntu-24
```
## Failure modes
* **Docker not found**: install Docker, or use `--tar` with a tarball.
* **Image not in local Docker**: `docker pull ` first, or use `--tar` with a saved tarball.
* **Server rejects the upload**: image is too large or malformed; check stderr for the server message.
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--name ` | derived from ref (Docker mode); required (`--tar` mode) | Image name to register on the server. | | `--tar ` | — | Skip Docker; stream the given tarball directly. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti image pull`](/docs/reference/cli/images/pull/) — server-side pull from public registries (no Docker needed)
* [`bhatti image save`](/docs/reference/cli/images/save/) — save a sandbox's filesystem as an image
* [API: `POST /images/import`](/docs/reference/api/#import-an-image)
# bhatti image list
> List all rootfs images available to the authenticated user.
List all rootfs images available to the authenticated user.
## Synopsis
```
bhatti image list
```
## Description
Lists images in three scopes:
* **System tiers** (`minimal`, `browser`, `docker`, `computer`) — visible to everyone.
* **Your own images** — created via `image pull`, `image import`, or `image save`.
* **Images shared with you** by another user.
## Examples
```bash
bhatti image list
```
```text
ID NAME SOURCE SIZE SCOPE
img_abc123 minimal built-in 186MB admin
img_def456 browser built-in 612MB admin
img_ghi789 python-3.12 oci:python:3.12 284MB user
img_jkl012 my-stack snapshot:dev 512MB user
```
```bash
# JSON for scripting
bhatti image list --json | jq '.[] | select(.user_id != "") | .name'
```
## Output fields (JSON)
```json
[
{"id": "img_abc123", "name": "minimal", "source": "built-in", "size_mb": 186, "user_id": ""},
{"id": "img_jkl012", "name": "my-stack", "source": "snapshot:dev", "size_mb": 512, "user_id": "usr_..."}
]
```
`user_id` is empty for system images; non-empty values denote ownership.
## Options
This command takes only global flags. See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti image pull`](/docs/reference/cli/images/pull/) / [`import`](/docs/reference/cli/images/import/) / [`save`](/docs/reference/cli/images/save/)
* [API: `GET /images`](/docs/reference/api/#list-images)
# bhatti image pull
> Pull a public OCI/Docker image and convert it to a bhatti rootfs.
Pull a public image from any OCI-compatible registry. The server fetches the image, flattens its layers, and writes them to an ext4 file usable as a sandbox rootfs.
## Synopsis
```
bhatti image pull [--name ] [--auth ]
```
## Description
The pull runs asynchronously on the server. The CLI returns a task ID, then polls `/tasks/` every 2 seconds and prints progress until the task completes or fails. **Press `Ctrl+C` to detach** — the pull continues on the server. Check progress later with `bhatti image list` (the image appears once it's ready).
If you don't pass `--name`, a name is derived from the ref:
* `python:3.12` → `python-3.12`
* `node:22-slim` → `node-22-slim`
* `ghcr.io/myorg/sandbox-base:latest` → `sandbox-base-latest`
`--auth` enables a single-shot HTTP basic auth header (`user:token`). For real private registries with rotating credentials, prefer the [`image import`](/docs/reference/cli/images/import/) flow — `docker pull` handles auth on your machine, then bhatti just receives the tarball.
If the same image was previously pulled with the same content digest, the server returns immediately without re-downloading.
## Examples
```bash
bhatti image pull python:3.12
```
```text
pulling python:3.12 as "python-3.12" (task: tsk_abc123)
resolving manifest...
downloading layers (45 MB / 142 MB)...
flattening to ext4...
done
```
```bash
# Custom name
bhatti image pull node:22-slim --name node-22
# Detach: press Ctrl+C after seeing the task ID
bhatti image pull ubuntu:24.04
^C
Interrupted. Pull continues on server.
Check status: bhatti image list
```
```bash
# Use the pulled image
bhatti create --name py --image python-3.12
```
## Authentication
For private registries, `--auth` works for simple `user:token` schemes. If the pull fails with `unauthorized` / `403`, the CLI prints a hint suggesting you use `image import` instead:
```text
This image may require authentication.
Pull it with Docker locally, then import:
docker pull ghcr.io/org/private:latest
bhatti image import ghcr.io/org/private:latest
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--name ` | derived from ref | Image name to register on the server. | | `--auth ` | — | HTTP basic auth credentials for the registry. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti image import`](/docs/reference/cli/images/import/) — local Docker / tarball import (handles private registries via local auth)
* [`bhatti create --image`](/docs/reference/cli/sandbox/create/) — use the pulled image
* [API: `POST /images/pull`](/docs/reference/api/#pull-an-image)
* [API: `GET /tasks/:id`](/docs/reference/api/#poll-an-async-task)
# bhatti image save
> Save a running sandbox's rootfs as a reusable image.
Snapshot a running sandbox's filesystem and save it as a reusable image.
## Synopsis
```
bhatti image save --name
```
## Description
Captures the current state of the sandbox's rootfs (every file written since boot) and writes it to a new ext4 image owned by the user. The sandbox keeps running; the save is read-only from its perspective.
Only the rootfs is captured — attached persistent volumes are **not** included. If your sandbox stores work in a volume, save the volume separately with [`bhatti volume clone`](/docs/reference/cli/volumes/clone/) and recreate the attachment when stamping out new sandboxes from the saved image.
The saved image is private to the user. To make it available to other users, use [`bhatti image share`](/docs/reference/cli/images/share/) (server-only).
## Examples
```bash
# Build a custom environment, then save it
bhatti create --name build --image minimal
bhatti exec build -- apt-get update && apt-get install -y nodejs
bhatti exec build -- npm install -g pnpm
bhatti image save build --name node-app
# saved "node-app" (512MB)
```
```bash
# Stamp out workers from the saved image
bhatti create --name worker-1 --image node-app
bhatti create --name worker-2 --image node-app
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--name ` | (required) | Name for the saved image. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti volume clone`](/docs/reference/cli/volumes/clone/) — for snapshotting volume data alongside an image
* [`bhatti snapshot create`](/docs/reference/cli/snapshots/create/) — full VM snapshot (memory + disk + processes), not just filesystem
* [API: `POST /sandboxes/:id/save-image`](/docs/reference/api/#save-a-sandbox-as-an-image)
# bhatti image share
> Share an image with specific users. Operates directly on the local SQLite database; requires DB access (server-only).
# bhatti image share server only
Share an image with one or more specific users.
## Synopsis
```
bhatti image share --user [--user ]...
bhatti image share --list
```
## Description
Adds an entry to the image-share table for each `` so that user can reference the image with `--image ` from their sandbox creation calls. Sharing is per-user; there's no "public" or "everyone" mode.
The command operates directly on the server's SQLite database — it isn't an HTTP API call. You must run it on the server, with access to `/var/lib/bhatti/state.db` (typically as `sudo`).
`--list` prints the users an image is currently shared with. `--unshare` is its own command, [`bhatti image unshare`](/docs/reference/cli/images/unshare/).
## Examples
```bash
# Share with two users
sudo bhatti image share spc-golden --user kowshik --user sumo
```
```text
shared "spc-golden" with: kowshik, sumo
```
```bash
# Inspect current shares
sudo bhatti image share spc-golden --list
```
```text
spc-golden shared with: kowshik, sumo
```
```bash
# Image not yet shared
sudo bhatti image share spc-golden --list
```
```text
spc-golden: not shared
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--user ` | — | User to share with. Repeatable. Required unless `--list`. | | `--list` | `false` | Print current shares for the image instead of adding new ones. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## Failure modes
* **Image not found**: bhatti can't see your image because of name typos or because it's owned by a different user. Check with `bhatti image list`.
* **User not found**: the `` doesn't match any user. Check with `bhatti user list`.
* **No `--user`**: the command refuses to do anything without an explicit user list (or `--list`). There's no global "share with all" mode.
## See also
* [`bhatti image unshare`](/docs/reference/cli/images/unshare/) — revoke access
* [`bhatti user list`](/docs/reference/cli/admin/user-list/) — find usernames
# bhatti image unshare
> Revoke image access from specific users. Server-only.
# bhatti image unshare server only
Revoke an image's share entries for one or more users.
## Synopsis
```
bhatti image unshare --user [--user ]...
```
## Description
Removes share-table entries for the listed users. After unsharing, those users will no longer see the image in [`bhatti image list`](/docs/reference/cli/images/list/) and can't reference it by name in `bhatti create --image`.
Sandboxes the affected users already created from the image keep running — they have their own copy-on-write rootfs and don't reference the original.
Like [`image share`](/docs/reference/cli/images/share/), this operates directly on the local SQLite database. Requires DB access on the server (typically `sudo`).
## Examples
```bash
sudo bhatti image unshare spc-golden --user sumo
```
```text
unshared "spc-golden" from: sumo
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--user ` | (required) | User to revoke. Repeatable. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti image share`](/docs/reference/cli/images/share/) — add a share
* [`bhatti image share --list`](/docs/reference/cli/images/share/) — see who has access
# Networking & sharing
> Publish ports with public URLs, share browser-based shells.
Expose sandbox services to the world (or a teammate) without an API key on the receiver’s side.
| Command | Description |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| [`bhatti publish`](/docs/reference/cli/networking/publish/) | Publish a sandbox port at a public URL. Cold sandboxes wake on the first request. |
| [`bhatti unpublish`](/docs/reference/cli/networking/unpublish/) | Remove a published port. |
| [`bhatti share`](/docs/reference/cli/networking/share/) | Generate a one-shot URL that opens an interactive terminal in the browser. |
Both `publish` and `share` produce URLs that don’t require an API key. Treat them like any other secret — anyone with the URL has access.
## Quick patterns
[Section titled “Quick patterns”](#quick-patterns)
```bash
# Publish on a friendly subdomain
bhatti publish dev -p 3000 -a my-app
# → https://my-app.bhatti.sh
# Auto-generated alias (random suffix prevents URL guessing)
bhatti publish dev -p 3000
# → https://dev-k3m9x2.bhatti.sh
# Publish + give a teammate a browser shell in one call
bhatti publish dev -p 3000 -a demo --shell
# Share a sandbox shell over the web
bhatti share dev
# → https://api.bhatti.sh/_shell/#token=...
# Revoke any active share link
bhatti share dev --revoke
# Tear down a published port
bhatti unpublish dev -p 3000
```
## Domain mode vs. path-based
[Section titled “Domain mode vs. path-based”](#domain-mode-vs-path-based)
Public URLs at `https://.` only work when the server is configured with a [custom domain](/docs/managing/custom-domain/). Without one, `bhatti publish` falls back to a path-based proxy:
```text
http:///sandboxes//proxy//
```
That URL still works, but it requires Bearer auth. Useful for development; not suitable for sharing publicly. See [`bhatti publish`](/docs/reference/cli/networking/publish/) for the exact fallback behaviour.
# bhatti publish
> Publish a sandbox port with a public URL. Cold sandboxes wake on first request.
Publish a sandbox port at a public URL.
## Synopsis
```
bhatti publish -p [-a ] [--shell]
```
## Description
Creates a routing rule on the bhatti server: incoming requests to `https://.` get reverse-proxied to `localhost:` inside the sandbox.
**Aliases.** With `-a my-app`, the URL is `https://my-app.` exactly. Without `-a`, an alias is generated from the sandbox name with a random suffix (`dev-k3m9x2`) to prevent URL guessing — useful for ephemeral previews.
**Cold sandboxes wake automatically.** The first request after a sandbox went cold takes \~50ms longer than usual; subsequent requests are full speed.
**No auth.** Published URLs are public. Anyone with the URL can reach the port. There's no per-URL access control — share carefully.
**HTTP and WebSocket.** Both work through the proxy. WebSocket idle timeout is 10 minutes; HTTP body cap is 50 MB per request.
**`--shell` combo.** Pass `--shell` to also generate a [web shell token](/docs/reference/cli/networking/share/) in the same call. Useful for "send a teammate a preview URL and a debug terminal in one click".
**Without a custom domain configured.** If the server isn't running in [domain mode](/docs/managing/custom-domain/), there's no `.` URL. The CLI prints a fallback hint:
```text
Publishing requires a custom domain.
Setup: https://bhatti.sh/docs/managing/custom-domain
In the meantime, use the proxy URL directly:
http:///sandboxes//proxy//
```
The proxy URL works but requires Bearer auth, so it's developer-only.
## Examples
```bash
# Friendly subdomain
bhatti publish dev -p 3000 -a my-app
# Published: https://my-app.bhatti.sh
```
```bash
# Auto-generated alias
bhatti publish dev -p 3000
# Published: https://dev-k3m9x2.bhatti.sh
```
```bash
# Preview URL + browser shell, single call
bhatti publish dev -p 3000 -a demo --shell
# Published: https://demo.bhatti.sh
# Shell: https://api.bhatti.sh/_shell/#token=...
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `-p`, `--port ` | (required) | Port inside the sandbox to publish. | | `-a`, `--alias ` | auto-generated | Custom subdomain alias. Without this, an alias derived from the sandbox name with a random suffix is used. | | `--shell` | `false` | Also generate a [web shell URL](/docs/reference/cli/networking/share/) in the same call. The shell URL is printed alongside the published URL. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti unpublish`](/docs/reference/cli/networking/unpublish/) — remove a published port
* [`bhatti share`](/docs/reference/cli/networking/share/) — web shell URL on its own
* [Custom domain](/docs/managing/custom-domain/) — required for `.` URLs
* [API: `POST /sandboxes/:id/publish`](/docs/reference/api/#publish-a-port)
# bhatti share
> Generate a one-shot URL that opens an interactive terminal in the browser.
Generate a URL that opens an interactive terminal in the browser. No CLI install or API key needed on the receiver's side.
## Synopsis
```
bhatti share [--revoke]
```
## Description
Mints a fresh token bound to the sandbox and embeds it in the URL fragment (`#token=...`). The fragment is **not** sent to the server in HTTP request lines, so the token doesn't appear in access logs or referer headers — but anyone with the URL has shell access until the token is revoked.
**Each call invalidates the previous token.** Calling `bhatti share dev` again generates a new URL; the old one stops working. Only one share link is active at a time per sandbox.
**Scope.** A share token grants access *only* to the specific sandbox it was minted for. It is not interchangeable with the user's API key.
**Behaviour in the browser.** The page opens an xterm.js terminal connected via WebSocket; it's a full PTY session, identical to [`bhatti shell`](/docs/reference/cli/exec/shell/). Closing the tab detaches; the session keeps running and scrollback is preserved. Reopening the URL reattaches.
**Revoke** with `--revoke` — the token is deleted server-side and the URL stops working.
## Examples
```bash
bhatti share dev
```
```text
Shell: https://api.bhatti.sh/_shell/a1b2c3d4#token=k3m9x2qr...
```
```bash
# JSON for scripting
bhatti share dev --json
```
```json
{
"url": "https://api.bhatti.sh/_shell/a1b2c3d4#token=k3m9x2qr...",
"token": "k3m9x2qr..."
}
```
```bash
# Revoke
bhatti share dev --revoke
```
```text
Shell access revoked.
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--revoke` | `false` | Delete the active token for this sandbox. The shared URL stops working immediately. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti publish --shell`](/docs/reference/cli/networking/publish/) — preview URL + share URL in one call
* [`bhatti shell`](/docs/reference/cli/exec/shell/) — terminal-based interactive shell
* [`bhatti shell`](/docs/reference/cli/exec/shell/) — the CLI counterpart
* [API: `POST /sandboxes/:id/shell-token`](/docs/reference/api/#mint-a-shell-token)
# bhatti unpublish
> Remove a published port. The URL stops working immediately.
Remove a published port. The URL stops working immediately.
## Synopsis
```
bhatti unpublish -p
```
## Description
Deletes the routing rule. New requests to the previously published URL get a 404 from the proxy. In-flight WebSocket connections continue until the client closes them.
Publish rules are also automatically cleaned up when a sandbox is destroyed — `unpublish` is only needed when you want to keep the sandbox but stop exposing the port.
## Examples
```bash
bhatti unpublish dev -p 3000
```
```text
Unpublished port 3000
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `-p`, `--port ` | (required) | Port to unpublish. |
See [Global flags](/docs/reference/cli/#global-flags) for `--url`, `--token`, `--json`, `--timing`, `--data-dir`.
## See also
* [`bhatti publish`](/docs/reference/cli/networking/publish/)
* [API: `DELETE /sandboxes/:id/publish/:port`](/docs/reference/api/#unpublish-a-port)
# Sandbox commands
> Lifecycle commands for sandbox VMs — create, list, inspect, edit, stop, start, destroy.
Sandboxes are isolated Linux VMs. These commands cover the full lifecycle: create them, look at them, change mutable settings, pause and resume them on disk, and tear them down.
| Command | Description |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [`bhatti create`](/docs/reference/cli/sandbox/create/) | Create a new sandbox VM. Optional flags cover CPU, memory, env vars, init scripts, volumes, secrets, and file injection. |
| [`bhatti list`](/docs/reference/cli/sandbox/list/) | List your sandboxes (alias: `ls`). `-o wide` adds resource columns. |
| [`bhatti inspect`](/docs/reference/cli/sandbox/inspect/) | Show full details for one sandbox (alias: `info`) — kubectl-`describe` style. |
| [`bhatti edit`](/docs/reference/cli/sandbox/edit/) | Toggle mutable settings. Currently only `--keep-hot` / `--allow-cold`. |
| [`bhatti stop`](/docs/reference/cli/sandbox/stop/) | Snapshot to disk and free memory. |
| [`bhatti start`](/docs/reference/cli/sandbox/start/) | Resume from snapshot. Continues exactly where it left off. |
| [`bhatti destroy`](/docs/reference/cli/sandbox/destroy/) | Permanently destroy a sandbox (alias: `rm`). Volumes are detached, not deleted. |
All commands accept either the sandbox name or its ID. Names are easier; IDs are stable across renames.
## Quick patterns
[Section titled “Quick patterns”](#quick-patterns)
```bash
bhatti create --name dev --cpus 2 --memory 1024
bhatti ls -o wide
bhatti inspect dev
bhatti exec dev -- npm install
bhatti stop dev # save state, free memory
bhatti start dev # resume from where it stopped
bhatti destroy dev -y
```
In normal operation you don’t need `stop` and `start` — idle sandboxes are paused and resumed automatically. See [Thermal states](/docs/under-the-hood/thermal-states/) for the engineering. Use them to control thermal state explicitly when you know a sandbox will be idle for a while.
## See also
[Section titled “See also”](#see-also)
* [Execution & shells](/docs/reference/cli/exec/) — `exec`, `shell`, `ps`, `ports`
* [Files](/docs/reference/cli/files/) — read/write files inside a sandbox
* [Networking & sharing](/docs/reference/cli/networking/) — `publish`, `share`
* [API: `POST /sandboxes`](/docs/reference/api/#create-a-sandbox)
# bhatti create
> Create a new sandbox VM with custom resources, environment, init script, volumes, and secrets.
Create a new sandbox VM. Each sandbox is an isolated Linux environment with its own kernel, filesystem, and network interface — created in seconds, paused for free, resumed in microseconds.
## Synopsis
```
bhatti create [flags]
```
## Description
Boots a krucible microVM and registers it under the authenticated user. Returns once the guest agent is responding.
The created sandbox is in the `running` (hot) thermal state. Without `--keep-hot`, it pauses to *warm* after 30 seconds of inactivity and freezes to *cold* (memory snapshotted to disk, RAM freed) after 30 minutes. Any incoming request transparently wakes it.
`--volume` and `--secret` reference resources that must already exist (create them with [`bhatti volume create`](/docs/reference/cli/volumes/create/) and [`bhatti secret set`](/docs/reference/cli/secrets/set/)). `--file` reads a local file at submit time and writes its bytes to the guest path during boot — convenient for injecting config files without a volume.
When used with `--template`, request-side `--secret` and `--file` are merged into the template's defaults: secrets from both lists are resolved (request-side names that don't exist in your secret store return `400`); `--file` adds to the template (templates have no files of their own). For environment variables, `--secret` wins over `--env` for the same name.
`--cpus` accepts fractional values (e.g. `0.5`, `1.5`); the host scheduler runs vCPUs as time-shared threads, so fractional allocation maps to "you're entitled to \~N vCPU-seconds per second."
`--hugepages` uses 2MB hugepages for the VM's memory, which makes boot faster. Use it for short-lived sandboxes, not for long-lived agents.
The `--memory` server default is 1024 MB. The `--cpus` default is 1. Both are capped by the user's per-sandbox limits set via [`bhatti user create`](/docs/reference/cli/admin/user-create/).
## Examples
```bash
# Smallest possible sandbox
bhatti create --name dev
```
```bash
# Custom resources, env vars, and an init script
bhatti create --name api \
--cpus 2 --memory 2048 \
--env NODE_ENV=production,LOG_LEVEL=info \
--init "cd /workspace && npm install"
```
```bash
# Mount a persistent volume; create the volume first
bhatti volume create --name workspace --size 5120
bhatti create --name dev --volume workspace:/workspace
```
```bash
# Reference a stored secret as a guest env var
bhatti secret set OPENAI_KEY sk-...
bhatti create --name agent --secret OPENAI_KEY
```
```bash
# Inject a local config file at boot
bhatti create --name worker --file ./config.json:/etc/worker/config.json
```
```bash
# Use a non-default rootfs image (browser tier with Chromium + Playwright)
bhatti create --name scraper --image browser
```
```bash
# Autonomous agent — never paused, even when idle
bhatti create --name agent --init "hermes gateway" --keep-hot
```
## Options
\| Flag | Default | Description | | ---- | ------- | ----------- | | `--name ` | auto-generated | Sandbox name. Must match `[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}`. | | `--cpus ` | `1` | Number of vCPUs. Fractional values allowed (e.g. `0.5`). Capped by per-sandbox limit. | | `--memory ` | `0` (server uses `1024`) | Memory in MB. `0` means use the server default. Capped by per-sandbox limit. | | `--disk-size ` | `0` (use image size) | Rootfs disk size in MB. `0` means inherit from the base image. | | `--env ` | — | Environment variables, comma-separated, applied to every command. | | `--init ` | — | Init script that runs at boot as an attachable session named `init`. Survives the boot path; visible in [`bhatti ps`](/docs/reference/cli/exec/ps/). | | `--keep-hot` | `false` | Disable thermal transitions. Use for sandboxes that maintain external connections (websockets, polling agents). | | `--hugepages` | `false` | Use 2MB hugepages. Faster boot. | | `--template ` | — | Create from a template. Template fields are defaults; flags here override them. | | `--image ` | — | Rootfs image. Built-in: `minimal`, `browser`, `docker`, `computer`. Or a name from [`bhatti image list`](/docs/reference/cli/images/list/). | | `--volume ` | — | Attach a persistent volume. Format: `name:mount[:ro]`. Repeatable. | | `--secret