Skip to content

Backup and restore

Last updated View as MarkdownAgent setup

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.

Prerequisites

  1. Create an R2 bucket:

    npx wrangler r2 bucket create my-backup-bucket
  2. Add the BACKUP_BUCKET R2 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_ENDPOINT to vars. For an EU bucket, use https://<ACCOUNT_ID>.eu.r2.cloudflarestorage.com.

  3. Store R2 API credentials as secrets:

    npx wrangler secret put R2_ACCESS_KEY_ID
    npx wrangler secret put R2_SECRET_ACCESS_KEY

    Create the token in the Cloudflare dashboard under R2 > Overview > Manage R2 API Tokens. Grant Object Read & Write on the backup bucket.

Create a backup

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.

Restore a backup

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.

Exclude generated caches

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.

Exclude gitignored files

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.

Checkpoint and roll back

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);
}

Store backup handles

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));
}

Set a name and TTL

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.

Clean up backup objects

Archives live at backups/{backupId}/data.sqsh and backups/{backupId}/meta.json.

Replace the latest 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));
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));

Delete a backup by ID

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`,
]);

Delete backups by age

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);
	}
}

Use backup and restore in local development

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.

Fix path permissions

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/sandbox

If 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" });

Handle errors

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);
	}
}

Was this helpful?