Replace 0.12 createBackup() and restoreBackup() with Durable Object methods of the same names, built on DirectoryBackup. Your Worker calls them with the same options, and backups keep the 0.12 time to live. DirectoryBackup saves the directory to the same BACKUP_BUCKET binding as a compressed archive, and restores it as ordinary files. 0.12 saved a SquashFS image and mounted it over the directory. Backups that 0.12 created convert to 1.0 backups the first time you restore them.
To save the whole filesystem instead of one directory, use a Container snapshot. For more information, refer to Save and restore a sandbox with snapshots.
Your 1.0 class replaces the 0.12 Sandbox class. On this page it is MySandbox, with the container getter and the ensureRunning() and startContainer() methods from Replace the Sandbox class.
This page replaces createBackup(), restoreBackup(), their options, and the backup errors. DirectoryBackup reaches R2 through this.ctx.exports, which is undefined before compatibility date 2025-11-17, unless you add the enable_ctx_exports compatibility flag.
-
Install the package, if your Worker does not use it yet:
npm i @cloudflare/sandboxyarn add @cloudflare/sandboxpnpm add @cloudflare/sandboxbun add @cloudflare/sandbox -
Copy the helper binary into your image, if the
Dockerfiledoes not copy it yet:Dockerfiledockerfile COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shimUse the
cloudflare/sandboxtag that matches the installed package version. -
Keep the R2 binding named
BACKUP_BUCKET, and point it at the bucket that 0.12 wrote to. With presigned uploads, that is the bucket inBACKUP_BUCKET_NAME:{ "r2_buckets": [ { "binding": "BACKUP_BUCKET", "bucket_name": "my-sandbox-backups", }, ], }[[r2_buckets]] binding = "BACKUP_BUCKET" bucket_name = "my-sandbox-backups"After you delete the 0.12 class, remove
BACKUP_BUCKET_NAMEandBACKUP_BUCKET_ENDPOINTfrom your variables, andR2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEYfrom your secrets unless a bucket mount uses them. The container never receives bucket credentials for backups in 1.0. -
Add these declarations to the top level of
src/index.ts:src/index.tsts import { DirectoryBackup, type DirectoryBackupRecord, } from "@cloudflare/sandbox"; // Gives the Durable Object this.ctx.exports.DirectoryBackupGateway, // which moves each backup between the container and the bucket export { DirectoryBackupGateway } from "@cloudflare/sandbox"; // 0.12 kept a backup for three days unless you set ttl. const DEFAULT_TTL_SECONDS = 3 * 24 * 60 * 60; // The record your code stores in place of the 0.12 backup object. type Backup = DirectoryBackupRecord & { expiresAt: number }; // Restores read the expiry time from this object, because a caller // can change the expiresAt field of a record. const metaKey = (id: string) => `backups/${id}.meta.json`; // Like 0.12, refuse a backup that expires within a minute. const EXPIRY_MARGIN_MS = 60 * 1000; -
Add a
DirectoryBackupfield toMySandbox:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... private readonly backups = new DirectoryBackup( this.container, this.ctx.exports.DirectoryBackupGateway, { binding: "BACKUP_BUCKET", prefix: "backups/" }, ); }this.ctx.containerstays the same object for as long as the Durable Object runs, so oneDirectoryBackupobject serves every container that the Durable Object starts.If
startContainer()registers a catch-all withinterceptAllOutboundHttp(), as Move outbound rules sets up, callawait this.backups.intercept()in thetryblock ofstartContainer(), before the catch-all. Otherwise the catch-all receives backup traffic from the container, and backups and restores fail withBACKUP_TRANSFER. A container that was already running with the catch-all needs a restart. Refer tointercept(). -
Replace
createBackup(). In 0.12, your Worker called it on the sandbox:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, "ada"); const backup = await sandbox.createBackup({ dir: "/workspace/project", excludes: ["node_modules"], ttl: 24 * 60 * 60, });In 1.0, add a method with the same name and options to
MySandbox:src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... async createBackup(options: { dir: string; name?: string; excludes?: string[]; gitignore?: boolean; ttl?: number; }): Promise<Backup> { await this.ensureRunning(); const { excludes, ttl = DEFAULT_TTL_SECONDS, ...rest } = options; const backup = await this.backups.backup({ ...rest, // 0.12 matched each pattern from the top of the directory. exclude: excludes?.map((pattern) => `/${pattern}`), }); const expiresAt = Date.now() + ttl * 1000; await this.env.BACKUP_BUCKET.put( metaKey(backup.id), JSON.stringify({ expiresAt }), ); return { ...backup, expiresAt }; } }Your Worker calls it as before, on the stub that
getByName()returns. It returns a record with the ID, directory, size, SHA-256, and expiry time of the backup. Store it where your code stored the 0.12 backup object. A record lets a caller restore that backup, so keep records out of reach of the code in the sandbox.createBackup()also writes the expiry time tobackups/<ID>.meta.jsonin the bucket, as 0.12 wrote it tometa.json. TheexpiresAtfield of the record is for display only. -
Replace
restoreBackup():src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async restoreBackup(backup: DirectoryBackupRecord) { const meta = await this.env.BACKUP_BUCKET.get(metaKey(backup.id)); if (!meta) { throw new Error(`Backup ${backup.id} was not found`); } const { expiresAt } = await meta.json<{ expiresAt: number }>(); if (Date.now() + EXPIRY_MARGIN_MS > expiresAt) { throw new Error(`Backup ${backup.id} has expired`); } await this.ensureRunning(); await this.backups.restore(backup); return { success: true, dir: backup.dir, id: backup.id }; } }restoreBackup()reads the expiry time from the bucket, not from the record, so a caller cannot restore an expired backup by changingexpiresAt. A record from another sandbox restores too, as in 0.12, andrestore()checks the object against the SHA-256 in the record.restore()replaces the directory with the files in the backup, so files that the backup left out, such asnode_modules, are gone after a restore. In 0.12, the restored directory was a mount that ended with the container. In 1.0, the files stay in the container filesystem.A process whose working directory is inside the directory keeps the old, deleted directory after
restore(). A server started withcwd: "/workspace/project"is one such process. Restart the process after the restore, or have it open files by absolute path. In 0.12, the restore mounted over the path, so such a process saw the restored files. -
Deploy your Worker, then back up and restore a directory through the existing routes of your Worker.
createBackup()returns a record like this one:{ "id": "52046bdb-afb2-4f38-a14c-70d48a45a56f", "dir": "/workspace/project", "size": 445, "sha256": "31ffdbb8a126e88fce0ff3b273a7db8aae1c933ba446118cf2319d8e015ebf91", "format": "tar+zstd/1", "expiresAt": 1790465279938 }restoreBackup()with that record returns{"success":true,"dir":"/workspace/project","id":"52046bdb-afb2-4f38-a14c-70d48a45a56f"}, and a backup whose stored expiry time has passed throwsBackup <ID> has expired, even when the record has a laterexpiresAt.
Each 0.12 name has one of these replacements:
| 0.12 | 1.0 |
|---|---|
dir |
dir. 0.12 accepted only directories under /workspace, /home, /tmp, /var/tmp, and /app. DirectoryBackup accepts any absolute path, so check the directory in your code when a request chooses it. |
name |
name, stored in the record and in the custom metadata of the object |
ttl |
The expiry time in backups/<ID>.meta.json. As in 0.12, an expired backup stays in the bucket until you delete it with this.backups.delete(backup). Delete its .meta.json object at the same time. |
excludes |
exclude, in .gitignore syntax. createBackup() starts each pattern with /, because a .gitignore pattern without a / matches at any depth. |
gitignore |
gitignore. It applies the .gitignore files inside the directory and .git/info/exclude, and the image does not need git. |
localBucket, multipart, compression |
Remove them. Every backup goes through the R2 binding in parts. |
BackupNotFoundError |
SandboxBackupError with the code BACKUP_NOT_FOUND |
BackupExpiredError |
The error that your restoreBackup() throws |
InvalidBackupConfigError |
TypeError |
BackupCreateError, BackupRestoreError |
SandboxFileError for a Linux error, such as a directory that does not exist, or SandboxBackupError with the code BACKUP_TRANSFER or BACKUP_INTEGRITY |
For every error, refer to DirectoryBackup errors. For what a backup keeps, refer to What a backup contains.
0.12 stored each backup as backups/<ID>/data.sqsh and backups/<ID>/meta.json in the bucket, with presigned uploads and with localBucket: true alike. These R2 objects stay in the bucket after you deploy 1.0, and the 0.12 backup objects that your code stored stay where they are. To keep a backup, convert it the first time your code restores it: extract the SquashFS image into the directory, then back up the directory with DirectoryBackup.
-
Add
squashfs-toolsto your image:Dockerfiledockerfile RUN apt-get update && apt-get install -y --no-install-recommends squashfs-tools && rm -rf /var/lib/apt/lists/* -
Add a method to
MySandboxthat converts a 0.12 backup object:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async convert(old: { id: string; dir: string }): Promise<Backup> { const meta = await this.env.BACKUP_BUCKET.get(`backups/${old.id}/meta.json`); const image = await this.env.BACKUP_BUCKET.get(`backups/${old.id}/data.sqsh`); if (!meta || !image) { throw new Error(`Backup ${old.id} was not found`); } const { createdAt, ttl } = await meta.json<{ createdAt: string; ttl: number }>(); const expiresAt = Date.parse(createdAt) + ttl * 1000; if (Date.now() + EXPIRY_MARGIN_MS > expiresAt) { await image.body.cancel(); throw new Error(`Backup ${old.id} has expired`); } await this.ensureRunning(); // unsquashfs needs the whole image on disk, so it goes to /var/tmp first. const script = 'cat > "$1" && rm -rf -- "$2" &&' + ' unsquashfs -no-progress -d "$2" "$1" > /dev/null;' + ' code=$?; rm -f -- "$1"; exit $code'; const process = await this.container.exec( ["sh", "-c", script, "sh", `/var/tmp/${old.id}.sqsh`, old.dir], { stdin: image.body }, ); const output = await process.output(); if (output.exitCode !== 0) { throw new Error(new TextDecoder().decode(output.stderr)); } const backup = await this.backups.backup({ dir: old.dir }); await this.env.BACKUP_BUCKET.put( metaKey(backup.id), JSON.stringify({ expiresAt }), ); return { ...backup, expiresAt }; } }The converted backup keeps the expiry time that 0.12 gave the original, in its own
.meta.jsonobject. The container needs free disk space for the image and the extracted files together. On aliteinstance,squashfs-tools4.7 and later, as in Alpine 3.23, fail withFATAL ERROR: Requested memory size too large. For those versions, add-mem 64Mto theunsquashfscommand. Debian 13 installs version 4.6.1, which extracts onlitewith its default settings. -
Where your code calls
restoreBackup()with an object that 0.12 created, callconvert()instead. A 0.12 object has noformatfield.convert()leaves the files in the directory, so it also does the restore. Store the record that it returns in place of the 0.12 object. -
After you store the new record, delete the two 0.12 objects:
src/index.tsts await this.env.BACKUP_BUCKET.delete([ `backups/${old.id}/data.sqsh`, `backups/${old.id}/meta.json`, ]);
A converted directory keeps its files, empty directories, symbolic links, hard links, and permissions. Modification times keep whole seconds only, because SquashFS does not store fractions of a second. If unsquashfs fails, the directory is incomplete, and calling convert() again replaces it.