Snapshots & forks

Per-minute incremental encrypted snapshots, lazy restore with prefetch, and disk-only fork semantics.


Sandal snapshots every running sandbox continuously, not as a manual "save" step you have to remember, but as a background guarantee. This page covers what's actually captured, how restore gets fast again, and what forking does and doesn't copy.

Per-minute incremental encrypted snapshots#

While a sandbox is running or paused, Sandal takes an incremental snapshot roughly once a minute. Each snapshot only writes the blocks that changed since the last one (V4/RAFS content-addressed layering), so the steady-state cost of snapshotting is proportional to your write rate, not your disk size.

  • Incremental: only dirty blocks are written; a full restore replays the chain back to a base image.
  • Encrypted: snapshots are encrypted at rest with per-sandbox keys before they leave the host.
  • Automatic: no API call required, it happens on the minute for any sandbox that isn't deleted.
  • Retained across pause, stop, and archive: the state you had a minute before any of those transitions is recoverable.

What's captured#

  • Full root filesystem (everything under /, including anything you wrote via the files API)
  • Installed packages, build artifacts, node_modules, virtualenvs, whatever's on disk
  • Process working directory and environment variables set at snapshot time (as filesystem/process state, not live memory, except for pause, see below)

What's excluded#

  • In-flight network connections, a snapshot can't preserve an open TCP socket; anything mid-request needs to be retried after restore.
  • Live memory state, except for an explicit pause. Routine per-minute snapshots are disk-level, not memory-level, restoring from one reboots the kernel fresh. If you need exact in-memory continuity (a REPL's variables, an open file descriptor table), that's what pause/resume gives you, not the background snapshot chain.
  • Ephemeral mounts you've explicitly marked scratch (tmpfs-backed paths), if configured, these are wiped by design since they're meant to not survive a restart anyway.
  • Secrets injected only as environment variables at boot that aren't also persisted to disk, if you exported a token in a shell session but never wrote it anywhere, and the shell state itself isn't the thing being restored (see previous bullet), don't assume it round-trips.

Lazy restore + prefetch playbooks#

Restoring a full disk image before letting you touch a sandbox would make every restore as slow as the sandbox's total disk usage. Sandal doesn't do that, restore is lazy: the sandbox boots against the snapshot chain immediately, and blocks are pulled in on first access, on demand.

That's fast to start but can be slow on first touch for a cold sandbox that's never been warmed. Prefetch playbooks close that gap: a playbook is a recorded access pattern (which blocks a typical boot + first request actually touch) that Sandal replays speculatively, in parallel, right after a lazy restore starts, so by the time your exec or expose()'d request actually needs those blocks, they're often already local.

const sb = await sandal.sandboxes.resume(sandboxId, {
  prefetch: "playbook:node-24-web-boot", // built-in, or record your own
});

Playbooks are per-template and get more accurate the more a template is used. Sandal profiles real restores and refines the block-access trace over time. You don't need to hand-tune this for standard templates; it only becomes worth touching if you have an unusual first-request access pattern (e.g., a huge static asset directory scanned on boot) and want a custom playbook recorded against it.

Fork semantics#

A fork creates a new sandbox that starts from the exact disk state of the parent at the moment of forking, same files, same installed packages, same everything on disk, but is otherwise a clean sandbox, not a shared one.

const parent = await sandal.sandboxes.create({ template: "node-24" });
await parent.exec("pnpm install"); // do expensive setup once

const childA = await parent.fork();
const childB = await parent.fork();
// childA and childB both have node_modules already installed,
// and can now diverge independently

Fresh identity and credentials#

A fork is not a clone of the parent's identity. Every fork gets:

  • A new sandbox ID, independent from the parent's
  • A new set of DPoP-scoped tokens for its own preview URLs, the parent's expose() tokens do not carry over and will not route to the child
  • Its own TTL clock and lifecycle state, decoupled from the parent

If the parent's disk happens to contain long-lived credentials (an API key baked into a config file, an SSH key on disk), those bytes are copied, fork inherits disk contents, not process identity. Rotate anything sensitive per-child if forks are meant to have distinct blast radii.

v1 scope: disk-only#

Today, fork is disk-only, it copies filesystem state via the snapshot chain, and the child boots fresh from that disk (same cold/lazy-restore path as any other sandbox creation). It does not copy:

  • Live memory / process state of the parent (an in-progress computation in the parent's RAM does not appear in the child)
  • Open network connections or PTY sessions
  • The parent's running vs paused distinction, the child always starts from a boot, not a memory resume

For workloads that need many cheap variations of a fully-set-up environment (parallel test matrices, per-branch preview sandboxes, agent tree-search over a shared starting state), disk-only fork is already the expensive part solved: you pay the setup cost once and fork it N times in roughly constant time per fork, instead of re-running setup N times.

Roadmap: live fork#

Live fork, forking a running sandbox's memory state, not just its disk, so a child can resume mid-computation with an identical process image, is on the roadmap. It's a strictly harder problem than disk fork (memory pages, open file descriptors, and in-flight syscalls all need to be consistent at the fork boundary) and isn't available yet. Disk-only fork remains the supported primitive until then; design your workflows around "fork, then re-run the fast part" rather than "fork mid-computation."

Failure policy: never discard work#

Sandal's snapshot and restore paths are built around one hard rule: a failure in the platform should never be the reason your sandbox's work disappears.

Concretely:

  • If a snapshot write fails partway through, the previous complete snapshot in the chain remains the restore point. Sandal never leaves a sandbox pointing at a half-written snapshot.
  • If a host fails while a sandbox is running, the last successful per-minute snapshot is the recovery point; you lose at most the last snapshot interval's writes, not the sandbox.
  • archive() and delete() are treated very differently on purpose: archive is designed to be reversible (cold storage, restorable), delete is the only operation that's actually destructive, and it requires an explicit call, no automatic process ever deletes a sandbox's data as a side effect of being idle, over TTL, or out of quota. Auto-pause is what happens to neglected sandboxes, not auto-delete.

If you ever hit a case where platform behavior discarded sandbox state without an explicit delete() call from you, that's a bug, report it, don't work around it.