NodeOps
UK

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.

TypeScript
1import { createClient } from "@nodeops-createos/sandbox";
2
3const client = createClient();
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 with CREATEOS_SANDBOX_BASE_URL)
  • Auth: API key via the apiKey option or CREATEOS_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.

TypeScript
1import { createClient, CreateosSandboxNotFoundError } from "@nodeops-createos/sandbox";
2
3const client = createClient();
4
5// 1. Register the S3 bucket as a disk (idempotent by name).
6// The bucket must be reachable from the createos-sandbox agent, not just
7// 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, // optional
15 // use_path_style: true, // MinIO / R2 with custom domain
16 },
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";
27
28try {
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 instead
35 });
36
37 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);
41
42 // 2b. Live-attach a second disk to a running sandbox (alternative path).
43 // The sandbox must be in "running" state; paused sandboxes pick up
44 // new disks via CreateSandboxRequest.disks at create or fork time.
45 // await sandbox.attachDisk({ diskId: "other-disk", mountPath: "/mnt/other" });
46
47 // 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

  • detachDisk requires diskId to be the disk_<ulid> id, not the human-readable name. The detach handler matches the attachment row by raw id. attachDisk, client.disks.get, and client.disks.delete all accept either. Capture disk.id right after disks.create and pass it through.
  • mountPath is required on detachDisk. 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_status via sandbox.listDisks() if the mount fails.
  • bandwidth_quota_bytes is not a create-time field. Grow it post-create with sandbox.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.

TypeScript
1import { createClient } from "@nodeops-createos/sandbox";
2
3const client = createClient();
4
5// 1. Create the overlay network.
6const network = await client.networks.create({ name: "backend" });
7// network.id = "net_01abc…"
8
9let sandboxA: Awaited<ReturnType<typeof client.createSandbox>> | undefined;
10let sandboxB: Awaited<ReturnType<typeof client.createSandbox>> | undefined;
11
12try {
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 ]);
29
30 // 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);
41
42 // 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 }
47
48 // 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.attachNetwork requires the sandbox to be running. Use networks: [{ id }] in createSandbox if you want the sandbox to join at boot.
  • Overlay IPs come from networkView.members[].ip, not from SandboxView.ip. Poll client.networks.get() after create if the membership is still being programmed (ip is absent until then).
  • networks.delete may 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.

TypeScript
1import { createClient, pollUntil } from "@nodeops-createos/sandbox";
2
3const client = createClient();
4
5// Dockerfile rules: single FROM using an allowlisted createos-sandbox base
6// image. No COPY / ADD — layer content comes from RUN only.
7const DOCKERFILE = `FROM nodeops/sandbox:debian
8RUN apt-get update -qq \\
9 && apt-get install -y --no-install-recommends ripgrep ca-certificates \\
10 && rm -rf /var/lib/apt/lists/*
11`;
12
13const TEMPLATE_NAME = `rg-base-${Date.now()}`;
14
15// 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);
22
23try {
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 }
37
38 // 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 ? undefined
45 : `template build failed (${status}) — see build logs`,
46 timeoutMs: 600_000,
47 });
48 console.log("template ready:", tmpl.id);
49
50 // 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 });
56
57 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:

TypeScript
1const log = await client.templates.logs(tmpl.id);
2console.log(log);

Or re-fetch the template with its Dockerfile included:

TypeScript
1const detail = await client.templates.get(tmpl.id, { include: "dockerfile" });
2console.log(detail.dockerfile);

Gotchas

  • templates.create returns immediately; the build is asynchronous. Always wait for status === "ready" before creating a sandbox on the template.
  • templates.followLogs may close the stream before emitting the final event. Always poll templates.get as a fallback (see step 3 above).
  • Dockerfile must use a single FROM pointing to an allowlisted createos-sandbox base image. COPY and ADD are not permitted. Bring content in via RUN.
  • Build time is unbounded. Pass timeoutMs: 600_000 (or longer) to followLogs and pollUntil.
  • TemplateStatus values: "pending""building""ready" | "failed".

100,000+ Builders. One Platform.

Get product updates, builder stories, and early access to features that help you ship faster.

NodeOps is the agentic operating system for production AI. CreateOS is its flagship product.