lightbox

Caching with named volumes

Share package caches, model weights, and build artifacts across sandboxes without rebuilding snapshots.

Snapshots bake everything in; once built, they’re immutable. But state that should change between launches — package caches, model weights, CI build artifacts — doesn’t belong in the snapshot. That’s what named volumes are for.

When to reach for a volume

GoalApproach
Speed up parallel npm install runsMount a shared npm-cache volume at /root/.npm
Avoid re-downloading model weights on every launchMount a hf-cache volume at /root/.cache/huggingface
Hand build artifacts from one sandbox to anotherBoth sandboxes mount the same volume
Persist long-running scratch dataMount once, write, mount from the next sandbox

Not for:

  • Shipping local source code in. Use a bind-mount (string value in mounts) — volumes are managed by the runtime and can’t be edited from the host easily.
  • Sharing read-only deps that never change. Bake them into the snapshot.

Pattern 1: Shared package cache

import { runInSandbox } from "@beamhop/lightbox";

async function runTest(name: string) {
  return runInSandbox(
    {
      snapshot: "node-ci",
      name: `test-${name}`,
      mounts: {
        "/work":     "./my-project",
        "/root/.npm": { volume: "npm-cache" }, // shared between all tests
      },
      workdir: "/work",
    },
    (sb) => sb.shell("npm ci && npm test"),
  );
}

await Promise.all(["unit", "integration", "e2e"].map(runTest));

The first job warms the npm-cache volume; the next two hit the cache cold. The volume is auto-created on first use.

Pattern 2: Producer / consumer

A snapshot bakes the build toolchain. A “builder” sandbox writes artifacts to a volume. A “runtime” sandbox reads them.

import { runInSandbox } from "@beamhop/lightbox";

// Step 1: build into the volume
await runInSandbox(
  {
    snapshot: "rust-toolchain",
    name: "build",
    mounts: {
      "/src":      "./my-rust-project",
      "/artifacts": { volume: "rust-artifacts" },
    },
    workdir: "/src",
  },
  (sb) => sb.shell("cargo build --release && cp target/release/myapp /artifacts/"),
);

// Step 2: run from the artifacts (different snapshot, same volume)
await runInSandbox(
  {
    snapshot: "minimal-runtime",
    name: "run",
    mounts: { "/app": { volume: "rust-artifacts" } },
  },
  (sb) => sb.exec("/app/myapp", ["--version"]),
);

Pattern 3: Pre-warm before scaling

If you’ll launch many sandboxes that all want the same cache, prime it once before going parallel — otherwise you race on the first cold population.

import { runInSandbox, launchSandbox } from "@beamhop/lightbox";

// Prime the cache (single sandbox).
await runInSandbox(
  {
    snapshot: "py-ml",
    name: "prime",
    mounts: { "/root/.cache/huggingface": { volume: "hf-cache" } },
  },
  (sb) => sb.shell("python -c \"from transformers import AutoModel; AutoModel.from_pretrained('bert-base-uncased')\""),
);

// Now fan out — every sandbox reuses the warm cache.
await Promise.all(
  Array.from({ length: 10 }, (_, i) => i).map(async (i) => {
    const sb = await launchSandbox({
      snapshot: "py-ml",
      name: `worker-${i}`,
      mounts: { "/root/.cache/huggingface": { volume: "hf-cache" } },
    });
    await sb.detach();
  }),
);

Configuring quotas and labels

For ad-hoc use, the auto-create-on-mount default is fine. For production caches you usually want a quota (so a runaway job doesn’t fill the disk) and labels (so you can sweep them later).

import { ensureVolume } from "@beamhop/lightbox";

await ensureVolume("npm-cache", {
  quotaMib: 4096,
  labels: { team: "platform", purpose: "ci-cache", ttl: "30d" },
});

ensureVolume is idempotent — calling it on an existing volume is a no-op. Quota and labels are only applied on initial creation; to change them you must remove + recreate (see Troubleshooting).

Cleanup

Volumes persist until removed. List them with listVolumes(), remove with removeVolume(name). Removing a volume deletes its contents.

import { listVolumes, removeVolume } from "@beamhop/lightbox";

const stale = (await listVolumes())
  .filter((v) => v.labels.ttl === "30d" && isOlderThan30Days(v.createdAt));

for (const v of stale) {
  await removeVolume(v.name);
}

See also