The DirectoryBackup class from @cloudflare/sandbox saves one directory as one object in your R2 bucket and restores it as ordinary files. A backup restores after the instance stops or after you deploy a new image. R2 keeps each backup until you delete it. In this example, the directory is a project in /workspace/project.
- A Worker with a Durable Object that starts a container with the Durable Object scheduling policy. To create one, refer to Run a Linux command.
You must have Docker running locally when you run wrangler deploy. For most people, the best way to install Docker is to follow the docs for installing Docker Desktop ↗︎. Other tools like Colima ↗︎ may also work.
You can check that Docker is running properly by running the docker info command in your terminal. If Docker is running, the command will succeed. If Docker is not running,
the docker info command will hang or return an error including the message "Cannot connect to the Docker daemon".
-
Install the package:
npm i @cloudflare/sandboxyarn add @cloudflare/sandboxpnpm add @cloudflare/sandboxbun add @cloudflare/sandbox -
Copy the helper binary into your image. If your project has no
Dockerfile, create one in your project root:Dockerfiledockerfile FROM node:24-trixie-slim COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim RUN mkdir /workspace CMD ["sleep", "infinity"]Use the
cloudflare/sandboxtag that matches the installed package version. Replace theFROMline with the base image your commands need. -
Create a bucket for the backups:
npx wrangler r2 bucket create sandbox-backupsyarn wrangler r2 bucket create sandbox-backupspnpm wrangler r2 bucket create sandbox-backupsIf Wrangler offers to add the bucket to your configuration, answer no. The next step adds the binding.
-
In
wrangler.jsonc, add thenodejs_compatflag, build theDockerfileas a named image in yourcontainersentry, and bind the bucket:{ "compatibility_flags": ["nodejs_compat"], "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "workspace": { "dockerfile": "./Dockerfile", }, }, }, ], "r2_buckets": [ { "binding": "BACKUPS", "bucket_name": "sandbox-backups", }, ], }compatibility_flags = [ "nodejs_compat" ] [[containers]] class_name = "MyContainer" scheduling_policy = "durable_object" [containers.images.workspace] dockerfile = "./Dockerfile" [[r2_buckets]] binding = "BACKUPS" bucket_name = "sandbox-backups"Then generate types:
npx wrangler typesyarn wrangler typespnpm wrangler types -
In
src/index.ts, exportDirectoryBackupGateway, and update theMyContainerclass so that its methods start theworkspaceimage and createDirectoryBackup:src/index.jsjs import { DirectoryBackup } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { DirectoryBackupGateway } from "@cloudflare/sandbox"; const project = "/workspace/project"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { container; backups; constructor(ctx, env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("The container binding is not configured"); } this.container = container; this.backups = new DirectoryBackup( container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "backups/" }, ); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } async startSandbox() { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } }src/index.tsts import { DirectoryBackup, type DirectoryBackupRecord, } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { DirectoryBackupGateway } from "@cloudflare/sandbox"; const project = "/workspace/project"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject<Env> { private readonly container: Container; private readonly backups: DirectoryBackup; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("The container binding is not configured"); } this.container = container; this.backups = new DirectoryBackup( container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "backups/" }, ); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } private async startSandbox(): Promise<void> { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } }The container sends the backup through
DirectoryBackupGateway, which writes it to theBACKUPSbucket. The container never receives R2 credentials, and it can reach only the object of the operation in progress.The constructor creates one
DirectoryBackupobject for the Durable Object, becausethis.ctx.containerstays the same object for as long as the Durable Object runs. -
Add a method to
MyContainerthat adds a line to a file in the project and returns the file, so you can check what a restore brings back:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async note(line?: string) { await this.startSandbox(); const script = line ? `mkdir -p ${project} && printf "%s\n" "$1" >> ${project}/notes.txt && cat ${project}/notes.txt` : `cat ${project}/notes.txt`; const process = await this.container.exec(["sh", "-c", script, "sh", line ?? ""]); const output = await process.output(); return new TextDecoder().decode(output.stdout); } } -
Add a method to
MyContainerthat backs up the project and stores the backup record:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async backup() { await this.startSandbox(); const backup = await this.backups.backup({ dir: project }); await this.ctx.storage.put(`backup:${backup.id}`, backup); return { id: backup.id, size: backup.size }; } }backup()returns a record with the ID, size, and SHA-256 of the object. The package keeps no list of backups. The Durable Object stores the record and looks it up by ID, so a request cannot point a restore at another object in the bucket.Stop the commands that write to the directory before you call
backup(). A file that changes during the backup can be saved partly old and partly new. -
Add a method to
MyContainerthat looks up a record and restores it:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async restore(id: string) { const backup = await this.ctx.storage.get<DirectoryBackupRecord>( `backup:${id}`, ); if (!backup) { return false; } await this.startSandbox(); await this.backups.restore(backup); return true; } }restore()replaces the directory and removes files that are not in the backup. Before it replaces anything, it checks the object against the SHA-256 in the record. Whenrestore()throwsSandboxFileErrororSandboxBackupError, it leaves the directory as it was. If the object no longer exists,restore()throwsSandboxBackupErrorwith the codeBACKUP_NOT_FOUND. If the object does not match the record, the code isBACKUP_INTEGRITY. For every error, refer to DirectoryBackup errors. -
Replace the default export of your Worker with routes that write a note, back up the project, and restore a backup:
src/index.jsjs export default { async fetch(request, env) { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|backups)(?:\/([0-9a-f-]{36})\/restore)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource, id] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (resource === "notes" && !id) { const line = request.method === "POST" ? await request.text() : undefined; return new Response(await sandbox.note(line)); } if (resource === "backups" && request.method === "POST") { if (!id) { return Response.json(await sandbox.backup(), { status: 201 }); } return (await sandbox.restore(id)) ? new Response(null, { status: 204 }) : new Response("Backup not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, };src/index.tsts export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|backups)(?:\/([0-9a-f-]{36})\/restore)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource, id] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (resource === "notes" && !id) { const line = request.method === "POST" ? await request.text() : undefined; return new Response(await sandbox.note(line)); } if (resource === "backups" && request.method === "POST") { if (!id) { return Response.json(await sandbox.backup(), { status: 201 }); } return (await sandbox.restore(id)) ? new Response(null, { status: 204 }) : new Response("Backup not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, } satisfies ExportedHandler<Env>;A backup holds every file in the directory, including credentials written in the sandbox. Authenticate callers first, so other people cannot back up or restore the files of another sandbox. For more information, refer to Sandbox security.
-
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
In the sandbox named
ada, write a note and back up the project. Replace the example hostname with theworkers.devURL that Wrangler prints:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notes --data "Keep this line." curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/backups --request POSTThe backup responds with its ID and its size in bytes, such as
{"id":"<BACKUP_ID>","size":217}. -
Write a second note, restore the backup, then read the notes. Replace
<BACKUP_ID>with the ID from the response:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notes --data "Lose this line." curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/backups/<BACKUP_ID>/restore --request POST curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notesThe restore responds with
204, and the notes are back to the state of the backup:Keep this line.
To leave out files, pass patterns in .gitignore syntax, relative to the directory. node_modules/ matches a directory with that name at any depth, and /build matches only at the top:
const backup = await this.backups.backup({
dir: project,
exclude: ["node_modules/", "/build"],
// Also apply the `.gitignore` files in the directory and `.git/info/exclude`,
// without `git` in the image
gitignore: true,
});To delete a backup, delete its object and its record:
export class MyContainer extends DurableObject<Env> {
// ...
async deleteBackup(id: string) {
const backup = await this.ctx.storage.get<DirectoryBackupRecord>(
`backup:${id}`,
);
if (backup) {
// `delete()` does not need a running container
await this.backups.delete(backup);
await this.ctx.storage.delete(`backup:${id}`);
}
}
}An object lifecycle rule that expires objects under backups/ also deletes backups. The rule leaves their records, and restoring one of those backups throws BACKUP_NOT_FOUND.
A backup that fails does not leave an object. If the Durable Object restarts during a backup, the incomplete multipart upload stays in the bucket until the default lifecycle rule of the bucket aborts it after seven days.
- DirectoryBackup API: what a backup keeps, one operation at a time per container, cancellation, and every error.
- Save and restore a sandbox with snapshots: keep the whole filesystem instead of one directory.
- Mount an R2 bucket: read and write files that other systems use.