npm i mac-sandbox -gsandbox startOr, to expose your API publicly via a Cloudflare Tunnel:
sandbox start -tSelf-hosted sandbox containers on Apple Silicon Macs, with Cloudflare Tunnel routing and a pairing-based auth flow. Built with Hono, TypeScript, and Apple Containers.
Note: This library is still actively being worked on. If you have feedback or run into issues, feel free to DM @jerrickhakim.
Most sandbox solutions are cloud-hosted and charge per execution. This project creates an API layer that lets you self-host sandboxes on hardware you already own — your Mac. If you have an Apple Silicon Mac sitting around, you can run isolated, ephemeral containers locally and expose them publicly through Cloudflare Tunnels, with no third-party sandbox provider needed.
Your Mac (port 4000)
├── Sandbox API server ← you talk to this
└── Apple Container(s) ← your image, your app
- The Sandbox API runs on your Mac at port 4000 and manages container lifecycle.
- Each sandbox runs your own container image — bring whatever stack you need.
- Containers can be exposed publicly via Cloudflare Tunnels (named or free
trycloudflare.com).
- Apple Silicon Mac (M1/M2/M3/M4)
- Node.js ≥ 20
- Apple Containers CLI (
container) - cloudflared (auto-installed by
sandbox setup)
npm install -g mac-sandboxBuild any Linux container image that suits your workload and tag it:
container build -t sandbox:latest /path/to/your/DockerfileThen point the CLI at it:
sandbox start --image my-image:latestVerify available images:
container image listIf the build fails, run:
container system kernel set --recommended --force
sandbox <command> [options]
COMMANDS:
start Start the sandbox server (auto-installs dependencies)
setup Install required dependencies (cloudflared, container CLI)
health Check system dependencies
pair Generate a new pairing code
tokens List all active auth tokens
list List all running containers
delete Delete all containers
destroy Stop container system and delete all containers + volumes
OPTIONS:
--port, -p Port to run the server on (default: 4000)
--tunnel, -t Enable Cloudflare tunnel (generates a public QR code URL)
--image, -i Container image name (default: sandbox)
--skip-setup Skip dependency check on startup
Local network mode (default):
sandbox startPrints a QR code, a 6-digit pairing code, and the local network URL (e.g. http://192.168.1.100:4000). Scan the QR code or POST the code to /pairing/confirm from any device on the same network.
Tunnel mode:
sandbox start -tSame as above, but the QR code points to a public trycloudflare.com URL so remote devices can pair.
1. sandbox start → server starts, displays QR code + 6-digit code
2. Client scans QR or POSTs code → POST /pairing/confirm
3. Server returns Bearer token → client stores it
4. Token used on all API calls → Authorization: Bearer <token>
POST /pairing/confirm
Content-Type: application/json
{
"code": "ABC123",
"deviceName": "My Laptop" // optional
}Response:
{ "token": "tok_secret..." }| Variable | Default | Description |
|---|---|---|
PORT |
4000 |
Port the sandbox API server listens on |
SANDBOX_IMAGE |
sandbox |
Container image name used when spawning sandboxes |
FLAGS_DESTROY_CONTAINERS |
— | Set "true" to stop & delete all containers on server shutdown |
FLAGS_PRESERVE_SANDBOX_TUNNELS |
— | Set "true" to also tear down per-sandbox Cloudflare tunnels on shutdown |
AUTO_INSTALL_CONTAINER_PKG |
— | Set "1" to run sudo installer silently instead of opening the GUI installer |
Skip these if you use free trycloudflare.com tunnels and pass flags.tunnel: false when creating sandboxes.
| Variable | Description |
|---|---|
CLOUDFLARE_TUNNEL_API_KEY |
Cloudflare API token with tunnel permissions |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare account ID |
CLOUDFLARE_ZONE_ID |
Cloudflare zone ID for your domain |
All sandbox endpoints require Authorization: Bearer <token>.
POST /sandbox/create{
"cpus": 2,
"memory": "4G",
"storage": "10G",
"volumeId": "existing-volume-id",
"flags": {
"tunnel": false,
"lan": true
},
"healthCheck": {
"url": "tunnel",
"timeout": 5000,
"interval": 1000,
"maxAttempts": 10,
"destroy": true
},
"env": {
"MY_VAR": "my-value"
}
}env is an arbitrary key/value map passed as environment variables to your container. Use it however your image expects.
storage options: 1G 5G 10G 15G 20G 25G 30G 32G 64G 128G
Response:
{
"id": "abc123",
"ipAddress": "192.168.64.5",
"urls": {
"tunnel": "https://abc.trycloudflare.com",
"lan": "http://192.168.1.100:8080",
"container": "http://192.168.64.5:80"
}
}| Method | Endpoint | Description |
|---|---|---|
GET |
/sandbox/list |
List all sandboxes |
GET |
/sandbox/:id |
Get sandbox details |
GET |
/sandbox/:id/health |
Check container reachability |
GET |
/sandbox/:id/inspect |
Full inspection: sandbox + container + volume info |
DELETE |
/sandbox/:id |
Stop and remove sandbox (?preserveStorage=true keeps the volume) |
POST /sandbox/:id/tunnel{
"tunnelToken": "<from-cloudflare>",
"tunnelId": "<tunnel-uuid>",
"url": "https://abc123.yourdomain.com",
"checkhealth": true
}POST /sandbox/:id/exec{
"command": "npm",
"args": ["run", "build"],
"timeout": 30000,
"user": "node"
}Response:
{
"success": true,
"exitCode": 0,
"stdout": "...",
"stderr": "",
"durationMs": 1234
}POST /sandbox/:id/exec/streamSame request body as /exec. Returns a Server-Sent Events stream with events:
| Event | Payload |
|---|---|
stdout |
Base64-encoded stdout chunk |
stderr |
Base64-encoded stderr chunk |
exit |
{ exitCode, signal } |
error |
{ error } |
ping |
{ timestamp } (keepalive every 15s) |
Persistent volumes can be attached to sandboxes at /workspace.
| Method | Endpoint | Description |
|---|---|---|
POST |
/volume/create |
Create a volume |
GET |
/volume/list |
List all volumes |
GET |
/volume/:id |
Get volume details |
DELETE |
/volume/:id |
Delete a volume |
POST |
/volume/batch-delete |
Delete multiple volumes |
POST /volume/create{
"id": "my-volume",
"size": "10G",
"label": "my-label"
}Pass volumeId in the create sandbox request to mount an existing volume at /workspace:
{ "volumeId": "my-volume", ... }Or pass storage: "10G" to create and mount a new volume automatically.
The server serves interactive API docs:
- Scalar UI:
http://localhost:4000/reference - OpenAPI JSON:
http://localhost:4000/doc
Minimal backend code to create a sandbox and wire up a Cloudflare tunnel:
import axios from "axios";
const api = axios.create({
baseURL: "https://xyz.trycloudflare.com", // from pairing
headers: { Authorization: `Bearer ${platformToken}` },
});
// 1. Create sandbox
const { data } = await api.post("/sandbox/create", {
cpus: 2,
memory: "4G",
env: {
// pass whatever env vars your image expects
MY_VAR: "my-value",
},
});
const { id: sandboxId, ipAddress } = data;
// 2. Create a named Cloudflare tunnel + CNAME pointing to the container
const { tunnelId, tunnelToken } = await createCloudflareTunnel({
hostname: `${sandboxId}.yourdomain.com`,
localServiceUrl: `http://${ipAddress}:80`,
});
// 3. Send tunnel credentials to Mac so cloudflared starts in the container
await api.post(`/sandbox/${sandboxId}/tunnel`, {
tunnelToken,
tunnelId,
url: `https://${sandboxId}.yourdomain.com`,
checkhealth: true,
});interface SandboxConfig {
cpus?: number;
memory?: string;
storage?: "1G" | "5G" | "10G" | "15G" | "20G" | "25G" | "30G" | "32G" | "64G" | "128G" | null;
volumeId?: string;
flags?: { tunnel?: boolean; lan?: boolean };
healthCheck?: {
url?: "tunnel" | "lan" | "container";
timeout?: number;
interval?: number;
maxAttempts?: number;
destroy?: boolean;
};
env?: Record<string, string>;
}
interface SandboxEntry {
id: string;
ipAddress: string;
hostPort: number;
createdAt: number;
volume: string | null;
urls: {
tunnel: string | null;
lan: string | null;
container: string;
};
}
interface ExecRequest {
command: string;
args?: string[];
timeout?: number;
user?: string;
}
interface ExecResponse {
success: boolean;
command: string;
args?: string[];
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
}| Issue | Solution |
|---|---|
| Container build fails | container system kernel set --recommended --force |
| Build export error: "structure needs cleaning" | See Build Export Error below |
| Stuck containers | sandbox restart or sandbox destroy |
cloudflared not found |
sandbox setup |
| Container system not running | container system start |
If you see Error: failed to write compressed diff: lstat /tmp/containerd-mount*: structure needs cleaning:
# 1. Clean stale mounts
sudo rm -rf /tmp/containerd-mount*
# 2. Restart container system
container system stop && container system start
# 3. Retry build without cache
container build --no-cache -t sandbox:latest .If it still fails:
container system kernel set --recommended --force
container system start
container build -t sandbox:latest .MIT