Move your 0.12 bucket mounts to S3Mount, which mounts the same bucket prefix without bucket credentials in the container. S3Gateway in your Worker signs each storage request. In 0.12, mountBucket() put the credentials in the container, or mounted through an R2 binding.
Your 1.0 class replaces the 0.12 Sandbox class, and has the container getter, the ensureRunning() and startContainer() methods, and the ENV constant from Replace the Sandbox class. Its image has sandbox-shim, which S3Mount uses. S3Mount reaches S3Gateway through this.ctx.exports, which needs compatibility date 2025-11-17 or later, or the enable_ctx_exports flag.
This page replaces mountBucket(), unmountBucket(), their options, and their errors. To build the same mount in a new project, refer to Mount an R2 bucket.
-
Add FUSE and
s3fsto yourDockerfile:Dockerfiledockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates fuse3 git python3 s3fs \ && rm -rf /var/lib/apt/lists/* -
Export
S3Gatewayfrom the main module of your Worker:src/index.tsts import { S3Mount, SandboxS3MountError, } from "@cloudflare/sandbox"; // S3Mount sends each storage request to this entrypoint. export { S3Gateway } from "@cloudflare/sandbox";Then add the endpoint and name of the bucket as variables, and its credentials as secrets, as in steps 3 and 4 of Mount an R2 bucket. 0.12 read
AWS_*andR2_*variables from the environment.S3Mountuses only the credentials that you pass to it.1.0 cannot mount a bucket through an R2 binding. If your 0.12 code called
mountBucket()with a binding name and noendpoint, create an R2 API token with the Object Read & Write permission for that bucket. -
Replace
mountBucket()with a method that mounts the prefix for the sandbox:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, job); await sandbox.mountBucket(env.S3_BUCKET, "/data", { endpoint: env.S3_ENDPOINT, prefix: `/jobs/${job}/`, });src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... // One S3Mount serves every container of this Durable Object. private readonly mounts = new S3Mount( this.container, this.ctx.exports.S3Gateway, ); private async mountData(): Promise<void> { // The name that your Worker passed to getByName(), // or to getSandbox() in 0.12 const job = this.ctx.id.name; if (!job) { throw new Error("Open the sandbox with getByName()"); } await this.mounts.mount({ mountPath: "/data", source: { type: "s3", endpoint: this.env.S3_ENDPOINT, region: "auto", bucket: this.env.S3_BUCKET, credentials: { type: "static", accessKeyId: this.env.S3_ACCESS_KEY_ID, secretAccessKey: this.env.S3_SECRET_ACCESS_KEY, }, }, keyPrefix: `jobs/${job}`, access: "read-write", }); } }The options change as follows:
0.12 1.0 The bucketargumentsource.bucketendpointsource.endpoint, andregion, which isautofor R2prefix: "/jobs/ada/"keyPrefix: "jobs/ada". It cannot start with/, andS3Mountadds the trailing/readOnly: trueaccess: "read-only".accessis requireds3fsOptions: ["name=value"]s3fsOptions: { name: "value" }.S3Mountalways setsnomixupload, which 0.12 added only for R2, and rejects it herecredentialssource.credentials, withtype: "static"provider,credentialProxyRemove them. S3Gatewaysigns every request -
Mount the prefix in each method that uses it:
src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async listFiles(): Promise<string> { await this.ensureRunning(); await this.mountData(); const ls = await this.container.exec(["ls", "-la"], { cwd: "/data", env: ENV, }); const output = await ls.output(); return new TextDecoder().decode(output.stdout); } }A mount lasts until the container stops, in 0.12 and in 1.0. Calling
mount()again with the same request reuses the mount. A request with other settings for a mounted path throwsSandboxS3MountErrorwith codeS3_MOUNT_CONFLICT, where 0.12 threwInvalidMountConfigError. -
Replace
unmountBucket(), and check errors by theircode:src/index.ts (0.12)ts await sandbox.unmountBucket("/data");src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... async unmountData(): Promise<string> { try { await this.mounts.unmount("/data"); return "unmounted"; } catch (error) { if (SandboxS3MountError.is(error)) { return error.code; } throw error; } } }S3_MOUNT_BUSYmeans that a process still uses the path. Requests to the path stay denied. Stop the process and callunmount()again. The 0.12 errors map as follows:0.12 1.0 InvalidMountConfigErrorTypeErrorfor an invalid request, such as akeyPrefixthat starts with/, andS3_MOUNT_CONFLICTfor a path in useMissingCredentialsErrorNone. source.credentialsis requiredS3FSMountError,BucketMountErrorS3_MOUNT_FAILEDBucketUnmountErrorS3_MOUNT_BUSYwhen a process uses the path
Each mount registers an intercept for its own hostname. That intercept receives requests only if the mount runs before the interceptAllOutboundHttp() call from Move outbound rules. A mount after that call fails with cannot verify the new FUSE connection, because your Outbound entrypoint receives the storage requests. Registering the catch-all again later does not affect a mount that came first.
Mount the prefix in the try block of startContainer(), before intercept():
export class MySandbox extends DurableObject<Env> {
// ...
private async startContainer(): Promise<void> {
const newContainer = !this.container.running;
if (newContainer) {
this.container.start({
image: this.container.images.sandbox,
instance: "standard-1",
env: ENV,
enableInternet: ENABLE_INTERNET,
});
}
try {
if (newContainer) {
// Replaces onStart().
await this.ctx.storage.put("startedAt", Date.now());
}
// Mount before the catch-all intercept.
await this.mountData();
await this.intercept();
await this.trust();
await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
} catch (error) {
// The next request starts a new container.
await this.container.destroy();
throw error;
}
}
}Remove the mountData() calls from the other methods. A path that you mount after intercept() does not work, so mount every path that the sandbox needs here. To mount a path later, open a new sandbox. A restarted Durable Object runs startContainer() again, and mount() reuses a mount that already matches.
Each mount uses one of the 64 intercept targets of the container, and unmount() does not free it. Move outbound rules uses two. Give each job its own sandbox name instead of mounting a new prefix for each job in one container.
0.12 mounted with localBucket: true under wrangler dev by copying files between the container and an R2 binding. S3Mount works under wrangler dev with an S3-compatible server on your machine instead. Refer to Develop locally.
A 0.12 mount ends with the 0.12 container, and the objects stay in the bucket. After the switch, the mount path does not exist in the new container until mountData() runs. Files that 0.12 wrote under prefix: "/jobs/ada/" appear at the same paths under keyPrefix: "jobs/ada".
If your 0.12 class also had outbound rules, the stored rules can include handlers named r2EgressMount and s3CredentialProxyMount for mounts that were active at the switch. The entrypoint from Move outbound rules skips them.
After you deploy the switch, call listFiles() in a sandbox that mounted its prefix in 0.12. It lists the files that 0.12 wrote:
total 9
drwxrwxrwx 1 root root 4096 Jan 1 1970 .
drwxr-xr-x 1 2346 2346 140 Sep 27 20:20 ..
-rw-r--r-- 1 root root 20 Sep 27 20:11 hello.txt
drwxr-xr-x 1 root root 4096 Sep 27 20:11 subthis.mounts.inspect("/data") reports the state of the mount. A working mount returns an attachment status of managed, a fuse status of connected, and a gateway status of reachable with an upstream status of usable.