How-to: disks, networks, and custom templates
Three independent recipes. Each has a self-contained code block you can adapt; they share the same import and client setup.
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();4// reads CREATEOS_SANDBOX_API_KEY + CREATEOS_SANDBOX_BASE_URL from env
See DisksApi / NetworksApi / TemplatesApi for full method signatures. Per-sandbox operations are covered in Sandbox.
At a glance
- Package:
@nodeops-createos/sandbox(npm) - Import:
import { createClient } from "@nodeops-createos/sandbox" - Base URL:
https://api.sb.createos.sh— override withCREATEOS_SANDBOX_BASE_URL - Auth: API key via the
apiKeyoption orCREATEOS_SANDBOX_API_KEY
1. Attach an S3-backed disk
Problem
You want a sandbox to read and write files that outlive the VM — stored durably in an S3-compatible bucket — without bundling them into the rootfs image.
Solution
Register the bucket once as a named disk, then mount it at sandbox create
time via CreateSandboxRequest.disks (boot-time) or live-attach it to a
running sandbox with sandbox.attachDisk. Detach before destroying so the
bucket is flushed cleanly, then delete the disk registration when you no
longer need it.
TypeScript1import { CreateosSandboxClient, CreateosSandboxNotFoundError } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();45// 1. Register the S3 bucket as a disk (idempotent by name).6// The bucket must be reachable from the createos-sandbox agent, not just7// from this machine. Verify connectivity before registering.8const disk = await client.disks.create({9 name: "my-data", // ^[a-z0-9][a-z0-9-]{0,62}$10 kind: "s3",11 config: {12 bucket: process.env.S3_BUCKET!,13 endpoint: process.env.S3_ENDPOINT!,14 region: process.env.S3_REGION, // optional15 // use_path_style: true, // MinIO / R2 with custom domain16 },17 credentials: {18 access_key: process.env.S3_ACCESS_KEY!,19 secret_key: process.env.S3_SECRET_KEY!,20 },21});22// Capture the resolved disk_<ulid> id immediately.23// detachDisk requires this id — it does NOT resolve disk names.24// attachDisk and client.disks.* accept either name or id.25const DISK_ID = disk.id; // "disk_01abc…"26const MOUNT = "/mnt/data";2728try {29 // 2a. Mount at boot via CreateSandboxRequest.disks (preferred).30 const sandbox = await client.createSandbox({31 shape: "s-4vcpu-4gb",32 rootfs: "devbox:1",33 disks: [{ disk_id: DISK_ID, mount_path: MOUNT }],34 // sub_path: "project/assets", // expose a bucket sub-folder instead35 });3637 try {38 // 3. Use the mount — files written here persist to S3.39 const result = await sandbox.runCommand("ls", ["-la", MOUNT]);40 console.log(result.result.stdout);4142 // 2b. Live-attach a second disk to a running sandbox (alternative path).43 // The sandbox must be in "running" state; paused sandboxes pick up44 // new disks via CreateSandboxRequest.disks at create or fork time.45 // await sandbox.attachDisk({ diskId: "other-disk", mountPath: "/mnt/other" });4647 // 4. Detach before destroy — use the disk_<ulid> id, not the name.48 await sandbox.detachDisk({ diskId: DISK_ID, mountPath: MOUNT });49 // Returns { detached: boolean }. Bucket contents are untouched.50 } finally {51 await sandbox.destroy();52 }53} finally {54 // 5. Delete the disk registration (bucket contents are untouched).55 await client.disks.delete(disk.name).catch((e) => console.warn(e));56}
Gotchas
detachDiskrequiresdiskIdto be thedisk_<ulid>id, not the human-readable name. The detach handler matches the attachment row by raw id.attachDisk,client.disks.get, andclient.disks.deleteall accept either. Capturedisk.idright afterdisks.createand pass it through.mountPathis required ondetachDisk. The same disk may be mounted at multiple paths; the composite key is(sandbox, disk, mountPath).- The bucket must be reachable from the createos-sandbox agent's network,
not just from the machine running this script. A misconfigured endpoint
or missing credentials causes a mount error — check
mount_statusviasandbox.listDisks()if the mount fails. bandwidth_quota_bytesis not a create-time field. Grow it post-create withsandbox.rechargeBandwidth()if needed.
2. Connect sandboxes on a private overlay network
Problem
You want two or more sandboxes to talk to each other by IP without exposing traffic to the public internet.
Solution
Create a named overlay network, then either pass it in networks at
sandbox create time or attach a running sandbox with
sandbox.attachNetwork. After creation, look up per-sandbox overlay IPs
from client.networks.get(id).members — SandboxView.ip is the
management address, not the overlay address.
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();45// 1. Create the overlay network.6const network = await client.networks.create({ name: "backend" });7// network.id = "net_01abc…"89let sandboxA: Awaited<ReturnType<typeof client.createSandbox>> | undefined;10let sandboxB: Awaited<ReturnType<typeof client.createSandbox>> | undefined;1112try {13 // 2. Boot two sandboxes already joined to the network.14 // Alternatively, call sandbox.attachNetwork(network.id) on a running sandbox.15 [sandboxA, sandboxB] = await Promise.all([16 client.createSandbox({17 shape: "s-4vcpu-4gb",18 rootfs: "devbox:1",19 name: "node-a",20 networks: [{ id: network.id }],21 }),22 client.createSandbox({23 shape: "s-4vcpu-4gb",24 rootfs: "devbox:1",25 name: "node-b",26 networks: [{ id: network.id }],27 }),28 ]);2930 // 3. Resolve per-sandbox overlay IPs via networks.get().31 // networks.get() returns members with per-network IPs on detail GET.32 // SandboxView.ip is the management IP, not the overlay address —33 // always read overlay IPs from networkView.members.34 const networkView = await client.networks.get(network.id);35 const ipById = new Map(36 (networkView.members ?? []).map((m) => [m.sandbox_id, m.ip]),37 );38 const ipA = ipById.get(sandboxA.id);39 const ipB = ipById.get(sandboxB.id);40 console.log("overlay IPs:", ipA, ipB);4142 // 4. Sandboxes reach each other on the overlay by those IPs.43 if (ipA && ipB) {44 const ping = await sandboxB.runCommand("ping", ["-c", "3", ipA]);45 console.log(ping.result.stdout);46 }4748 // 5. Detach and clean up.49 await Promise.all([50 sandboxA.detachNetwork(network.id),51 sandboxB.detachNetwork(network.id),52 ]);53} finally {54 await Promise.allSettled([55 sandboxA?.destroy(),56 sandboxB?.destroy(),57 ]);58 // Delete may fail transiently if members are still tearing down server-side.59 // Retry to avoid leaking against the network quota.60 for (let attempt = 1; attempt <= 3; attempt++) {61 try {62 await client.networks.delete(network.id);63 break;64 } catch {65 if (attempt < 3) await new Promise((r) => setTimeout(r, 2000));66 }67 }68}
Gotchas
sandbox.attachNetworkrequires the sandbox to be running. Usenetworks: [{ id }]increateSandboxif you want the sandbox to join at boot.- Overlay IPs come from
networkView.members[].ip, not fromSandboxView.ip. Pollclient.networks.get()after create if the membership is still being programmed (ipis absent until then). networks.deletemay return a "network in use" error for a few seconds after sandbox destroy. Retry with a short delay rather than ignoring the error — uncleaned networks count against the per-account quota.
3. Build a custom rootfs template from a Dockerfile
Problem
You want a prebuilt rootfs image with custom packages or configuration so
sandboxes boot from it instantly, without re-running apt-get install on
every create.
Solution
client.templates.create accepts a Dockerfile and builds a rootfs image
server-side. Follow build progress with templates.followLogs (streaming),
then poll templates.get for terminal status, and finally pass the
template's id or name as rootfs in createSandbox.
TypeScript1import { CreateosSandboxClient, pollUntil } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();45// Dockerfile rules: single FROM using an allowlisted createos-sandbox base6// image. No COPY / ADD — layer content comes from RUN only.7const DOCKERFILE = `FROM nodeops/sandbox:debian8RUN apt-get update -qq \\9 && apt-get install -y --no-install-recommends ripgrep ca-certificates \\10 && rm -rf /var/lib/apt/lists/*11`;1213const TEMPLATE_NAME = `rg-base-${Date.now()}`;1415// 1. Submit the build. Returns immediately with status "pending".16const tmpl = await client.templates.create({17 name: TEMPLATE_NAME,18 dockerfile: DOCKERFILE,19 // base: "devbox:1", // override the base rootfs (empty = host default)20});21console.log("template id:", tmpl.id, "status:", tmpl.status);2223try {24 // 2. Stream build logs until the terminal event arrives.25 // Pass a generous timeoutMs — builds can outlast the default 60 s deadline.26 try {27 for await (const event of client.templates.followLogs(tmpl.id, { timeoutMs: 600_000 })) {28 if (event.line) process.stdout.write(event.line + "\n");29 if (event.final) {30 console.log("build finished:", event.status);31 break;32 }33 }34 } catch {35 // Stream may close before the final event; confirm status by polling below.36 }3738 // 3. Poll for terminal status — the log stream may close before "ready".39 await pollUntil({40 poll: () => client.templates.get(tmpl.id).then((t) => t.status),41 done: (status) => status === "ready",42 failed: (status) =>43 status === "pending" || status === "building"44 ? undefined45 : `template build failed (${status}) — see build logs`,46 timeoutMs: 600_000,47 });48 console.log("template ready:", tmpl.id);4950 // 4. Boot a sandbox on the template.51 // rootfs accepts the template id or its name.52 const sandbox = await client.createSandbox({53 shape: "s-4vcpu-4gb",54 rootfs: tmpl.id,55 });5657 try {58 const rg = await sandbox.runCommand("rg", ["--version"]);59 console.log(rg.result.stdout.trim());60 } finally {61 await sandbox.destroy();62 }63} finally {64 // 5. Delete the template when no longer needed.65 await client.templates.delete(tmpl.id).catch((e) => console.warn(e));66}
You can also fetch the full build log as plain text after the fact:
TypeScript1const log = await client.templates.logs(tmpl.id);2console.log(log);
Or re-fetch the template with its Dockerfile included:
TypeScript1const detail = await client.templates.get(tmpl.id, { include: "dockerfile" });2console.log(detail.dockerfile);
Gotchas
templates.createreturns immediately; the build is asynchronous. Always wait forstatus === "ready"before creating a sandbox on the template.templates.followLogsmay close the stream before emitting thefinalevent — always polltemplates.getas a fallback (see step 3 above).- Dockerfile must use a single
FROMpointing to an allowlisted createos-sandbox base image.COPYandADDare not permitted — bring content in viaRUN. - Build time is unbounded. Pass
timeoutMs: 600_000(or longer) tofollowLogsandpollUntil. TemplateStatusvalues:"pending"→"building"→"ready"|"failed".