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 { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const 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 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 { CreateosSandboxClient, CreateosSandboxNotFoundError } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
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).membersSandboxView.ip is the management address, not the overlay address.

TypeScript
1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
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 { CreateosSandboxClient, pollUntil } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
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 Workspace.

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

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.