This guide shows you how to snapshot a sandbox directory to R2 and restore it later.
Use backup and restore when a project directory such as /workspace should come back after the sandbox sleeps. For a separate persisted storage path, mount a bucket instead. If you mount a bucket over /workspace, the mount overlays files seeded by your image in production.
For why production restore uses an overlay, refer to Directory backups.
-
Create an R2 bucket:
npx wrangler r2 bucket create my-backup-bucket -
Add the
BACKUP_BUCKETR2 binding and presigned URL settings to your Wrangler configuration:{ "name": "my-sandbox-worker", "main": "src/index.ts", // Set this to today's date "compatibility_date": "2026-09-02", "compatibility_flags": ["nodejs_compat"], "containers": [ { "class_name": "Sandbox", "image": "./Dockerfile", }, ], "durable_objects": { "bindings": [ { "class_name": "Sandbox", "name": "Sandbox", }, ], }, "migrations": [ { "new_sqlite_classes": ["Sandbox"], "tag": "v1", }, ], "vars": { "BACKUP_BUCKET_NAME": "my-backup-bucket", "CLOUDFLARE_ACCOUNT_ID": "<YOUR_ACCOUNT_ID>", }, "r2_buckets": [ { "binding": "BACKUP_BUCKET", "bucket_name": "my-backup-bucket", }, ], }name = "my-sandbox-worker" main = "src/index.ts" # Set this to today's date compatibility_date = "2026-09-02" compatibility_flags = [ "nodejs_compat" ] [[containers]] class_name = "Sandbox" image = "./Dockerfile" [[durable_objects.bindings]] class_name = "Sandbox" name = "Sandbox" [[migrations]] new_sqlite_classes = [ "Sandbox" ] tag = "v1" [vars] BACKUP_BUCKET_NAME = "my-backup-bucket" CLOUDFLARE_ACCOUNT_ID = "<YOUR_ACCOUNT_ID>" [[r2_buckets]] binding = "BACKUP_BUCKET" bucket_name = "my-backup-bucket"If the bucket uses a jurisdiction-specific endpoint, add
BACKUP_BUCKET_ENDPOINTtovars. For an EU bucket, usehttps://<ACCOUNT_ID>.eu.r2.cloudflarestorage.com. -
Store R2 API credentials as secrets:
npx wrangler secret put R2_ACCESS_KEY_ID npx wrangler secret put R2_SECRET_ACCESS_KEYCreate the token in the Cloudflare dashboard ↗ under R2 > Overview > Manage R2 API Tokens. Grant Object Read & Write on the backup bucket.
import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const backup = await sandbox.createBackup({ dir: "/workspace" });import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const backup = await sandbox.createBackup({ dir: "/workspace" });The directory must be an absolute path under /workspace, /home, /tmp, /var/tmp, or /app.
Stop processes that write to the target directory, then restore:
import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const backup = await sandbox.createBackup({ dir: "/workspace" });
const result = await sandbox.restoreBackup(backup);import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const backup = await sandbox.createBackup({ dir: "/workspace" });
const result = await sandbox.restoreBackup(backup);In production, restore mounts a copy-on-write overlay. The mount is lost when the sandbox sleeps or the container restarts. Restore again from the stored handle.
The restore target is backup.dir. You can point that field at a different allowed directory than the one you originally backed up.
After a production restore, renaming a directory inside the restored tree can fail with EXDEV (cross-device link not permitted). Omit disposable generated directories from the backup, or delete them after restore. Vite's cache is one such directory:
const backup = await sandbox.createBackup({
dir: "/workspace/app",
excludes: ["node_modules/.vite"],
});const backup = await sandbox.createBackup({
dir: "/workspace/app",
excludes: ["node_modules/.vite"],
});await sandbox.restoreBackup(backup);
await sandbox.exec("rm -rf /workspace/app/node_modules/.vite");await sandbox.restoreBackup(backup);
await sandbox.exec("rm -rf /workspace/app/node_modules/.vite");This failure does not occur in wrangler dev, which extracts the archive. For overlay restore, refer to Directory backups.
To skip .gitignore matches such as node_modules/ or dist/ in a git repository:
const backup = await sandbox.createBackup({
dir: "/workspace",
gitignore: true,
});const backup = await sandbox.createBackup({
dir: "/workspace",
gitignore: true,
});If the directory is not inside a git repository, gitignore has no effect. If git is not installed in the container, the SDK logs a warning and continues without git-based exclusions. Nested .gitignore files apply.
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const checkpoint = await sandbox.createBackup({ dir: "/workspace" });
try {
await sandbox.exec("npm install some-experimental-package");
await sandbox.exec("npm run build");
} catch (error) {
await sandbox.restoreBackup(checkpoint);
}const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const checkpoint = await sandbox.createBackup({ dir: "/workspace" });
try {
await sandbox.exec("npm install some-experimental-package");
await sandbox.exec("npm run build");
} catch (error) {
await sandbox.restoreBackup(checkpoint);
}DirectoryBackup is serializable. Persist it to KV, D1, or Durable Object storage:
const backup = await sandbox.createBackup({
dir: "/workspace",
name: "deploy-v2",
ttl: 604800, // 7 days
});
await env.KV.put(`backup:${userId}`, JSON.stringify(backup));
const stored = await env.KV.get(`backup:${userId}`);
if (stored) {
await sandbox.restoreBackup(JSON.parse(stored));
}const backup = await sandbox.createBackup({
dir: "/workspace",
name: "deploy-v2",
ttl: 604800, // 7 days
});
await env.KV.put(`backup:${userId}`, JSON.stringify(backup));
const stored = await env.KV.get(`backup:${userId}`);
if (stored) {
await sandbox.restoreBackup(JSON.parse(stored));
}Names can be up to 256 characters. The default TTL is 3 days (259200 seconds). The SDK rejects an expired backup at restore time. It does not delete the R2 objects.
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const shortBackup = await sandbox.createBackup({
dir: "/workspace",
ttl: 600, // 10 minutes
});
const longBackup = await sandbox.createBackup({
dir: "/workspace",
name: "daily-snapshot",
ttl: 604800, // 7 days
});const sandbox = getSandbox(env.Sandbox, "my-sandbox");
const shortBackup = await sandbox.createBackup({
dir: "/workspace",
ttl: 600, // 10 minutes
});
const longBackup = await sandbox.createBackup({
dir: "/workspace",
name: "daily-snapshot",
ttl: 604800, // 7 days
});To delete expired objects automatically, add an R2 object lifecycle rule on the backups/ prefix. If your longest TTL is 7 days, expire objects older than 7 days.
Archives live at backups/{backupId}/data.sqsh and backups/{backupId}/meta.json.
if (previousBackup) {
await env.BACKUP_BUCKET.delete([
`backups/${previousBackup.id}/data.sqsh`,
`backups/${previousBackup.id}/meta.json`,
]);
}
const backup = await sandbox.createBackup({
dir: "/workspace",
name: "latest",
});
await env.KV.put("latest-backup", JSON.stringify(backup));if (previousBackup) {
await env.BACKUP_BUCKET.delete([
`backups/${previousBackup.id}/data.sqsh`,
`backups/${previousBackup.id}/meta.json`,
]);
}
const backup = await sandbox.createBackup({
dir: "/workspace",
name: "latest",
});
await env.KV.put("latest-backup", JSON.stringify(backup));await env.BACKUP_BUCKET.delete([
`backups/${backup.id}/data.sqsh`,
`backups/${backup.id}/meta.json`,
]);await env.BACKUP_BUCKET.delete([
`backups/${backup.id}/data.sqsh`,
`backups/${backup.id}/meta.json`,
]);List objects under backups/ and delete by upload time:
const listed = await env.BACKUP_BUCKET.list({ prefix: "backups/" });
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
for (const object of listed.objects) {
const ageMs = Date.now() - object.uploaded.getTime();
if (ageMs > sevenDaysMs) {
await env.BACKUP_BUCKET.delete(object.key);
}
}const listed = await env.BACKUP_BUCKET.list({ prefix: "backups/" });
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
for (const object of listed.objects) {
const ageMs = Date.now() - object.uploaded.getTime();
if (ageMs > sevenDaysMs) {
await env.BACKUP_BUCKET.delete(object.key);
}
}Pass localBucket: true so wrangler dev uses the BACKUP_BUCKET binding. Presigned URL credentials are not required.
const backup = await sandbox.createBackup({
dir: "/workspace",
localBucket: Boolean(env.LOCAL_DEV),
});
const result = await sandbox.restoreBackup(backup);const backup = await sandbox.createBackup({
dir: "/workspace",
localBucket: Boolean(env.LOCAL_DEV),
});
const result = await sandbox.restoreBackup(backup);Local restore extracts the archive with unsquashfs and replaces the directory. The stored handle's localBucket field selects the restore path.
createBackup() must read every file under the target directory. Files with mode 0600 or directories owned by another user cause BackupCreateError.
Set permissions in the image when you can. a+rX adds read permission on files and execute permission on directories:
RUN mkdir -p /home/sandbox && chmod -R a+rX /home/sandboxIf a process creates restrictive files at runtime, fix them before the backup:
await sandbox.exec("chmod -R a+rX /home/sandbox/.claude");
const backup = await sandbox.createBackup({ dir: "/home/sandbox" });import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
try {
const backup = await sandbox.createBackup({ dir: "/workspace" });
} catch (error) {
if (error.code === "INVALID_BACKUP_CONFIG") {
console.error("Configuration error:", error.message);
} else if (error.code === "BACKUP_CREATE_FAILED") {
console.error("Backup failed:", error.message);
}
}
try {
await sandbox.restoreBackup(backup);
} catch (error) {
if (error.code === "BACKUP_NOT_FOUND") {
console.error("Backup not found in R2:", error.message);
} else if (error.code === "BACKUP_EXPIRED") {
console.error("Backup TTL has elapsed:", error.message);
} else if (error.code === "BACKUP_RESTORE_FAILED") {
console.error("Restore failed:", error.message);
}
}import { getSandbox } from "@cloudflare/sandbox";
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
try {
const backup = await sandbox.createBackup({ dir: "/workspace" });
} catch (error) {
if (error.code === "INVALID_BACKUP_CONFIG") {
console.error("Configuration error:", error.message);
} else if (error.code === "BACKUP_CREATE_FAILED") {
console.error("Backup failed:", error.message);
}
}
try {
await sandbox.restoreBackup(backup);
} catch (error) {
if (error.code === "BACKUP_NOT_FOUND") {
console.error("Backup not found in R2:", error.message);
} else if (error.code === "BACKUP_EXPIRED") {
console.error("Backup TTL has elapsed:", error.message);
} else if (error.code === "BACKUP_RESTORE_FAILED") {
console.error("Restore failed:", error.message);
}
}- Directory backups - Overlay restore, local extract, and
EXDEV - Backups API - Methods, options, and types
- Storage API - Mount S3-compatible buckets
- R2 documentation - R2 buckets and credentials
- R2 lifecycle rules - Automatic object cleanup