Skip to content

Mount an R2 bucket

Last updated View as MarkdownAgent setup

The S3Mount class from @cloudflare/sandbox mounts one prefix of a bucket as a directory with s3fs. Your Worker keeps the R2 credentials and signs each storage request. In this example, a command reads an input file from the mount and writes a digest next to it.

Prerequisites

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".

Mount a bucket prefix

  1. Install the package:

    npm i @cloudflare/sandbox
  2. Create a Dockerfile in your project root with FUSE, s3fs, and the sandbox-shim binary that S3Mount uses:

    Dockerfiledockerfile
    FROM node:24-trixie-slim
    
    RUN apt-get update \
    	&& apt-get install -y --no-install-recommends ca-certificates fuse3 s3fs \
    	&& rm -rf /var/lib/apt/lists/*
    
    COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim
    
    CMD ["sleep", "infinity"]

    Use the cloudflare/sandbox tag that matches the @cloudflare/sandbox version you installed.

  3. In wrangler.jsonc, turn on nodejs_compat, add the endpoint and name of the bucket as variables, declare the credentials as secrets, and build the Dockerfile as a named image. Replace <ACCOUNT_ID> with your Cloudflare account ID:

    {
    	"compatibility_flags": ["nodejs_compat"],
    	"vars": {
    		"S3_ENDPOINT": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    		"S3_BUCKET": "sandbox-artifacts",
    	},
    	"secrets": {
    		"required": ["S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"],
    	},
    	"containers": [
    		{
    			"class_name": "MyContainer",
    			"scheduling_policy": "durable_object",
    			"images": {
    				"artifacts": {
    					"dockerfile": "./Dockerfile",
    				},
    			},
    		},
    	],
    }
    compatibility_flags = [ "nodejs_compat" ]
    
    [vars]
    S3_ENDPOINT = "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
    S3_BUCKET = "sandbox-artifacts"
    
    [secrets]
    required = [ "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY" ]
    
    [[containers]]
    class_name = "MyContainer"
    scheduling_policy = "durable_object"
    
    [containers.images.artifacts]
    dockerfile = "./Dockerfile"

    secrets.required adds the secrets to the generated types and makes wrangler deploy fail when they are missing. To mount a bucket from another S3-compatible service, change S3_ENDPOINT and S3_BUCKET, and use the credentials for that service.

  4. Generate types, then add the Access Key ID and Secret Access Key from the R2 API token as secrets:

    npx wrangler types
    npx wrangler secret put S3_ACCESS_KEY_ID
    npx wrangler secret put S3_SECRET_ACCESS_KEY
  5. Import S3Mount, and export S3Gateway from the main module of your Worker:

    src/index.jsjs
    import { S3Mount } from "@cloudflare/sandbox";
    
    // S3Mount sends storage requests from the sandbox to this entrypoint.
    export { S3Gateway } from "@cloudflare/sandbox";
    src/index.tsts
    import { S3Mount } from "@cloudflare/sandbox";
    
    // S3Mount sends storage requests from the sandbox to this entrypoint.
    export { S3Gateway } from "@cloudflare/sandbox";

    s3fs in the sandbox sends each storage request to an address that the container intercepts. The intercept delivers the request to S3Gateway in your Worker. S3Gateway checks that the request stays within the bucket, prefix, and access mode of the mount. Then it signs the request with your credentials and sends it to R2.

  6. Add a constructor to your Durable Object. It creates one S3Mount object, and sets the inactivity timeout again when a restarted Durable Object finds the container running:

    src/index.tsts
    const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
    
    export class MyContainer extends DurableObject<Env> {
    	private readonly container: Container;
    	private readonly mounts: S3Mount;
    
    	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.mounts = new S3Mount(container, ctx.exports.S3Gateway);
    
    		if (container.running) {
    			void ctx.blockConcurrencyWhile(() =>
    				container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
    			);
    		}
    	}
    }

    this.ctx.container stays the same object for as long as the Durable Object runs, so one S3Mount object serves every container that the Durable Object starts.

    Then add a method that starts the sandbox and mounts the prefix for the job:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	private async mountArtifacts(job: string) {
    		if (!this.container.running) {
    			this.container.start({
    				image: this.container.images.artifacts,
    				enableInternet: false,
    			});
    			await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
    		}
    
    		await this.mounts.mount({
    			mountPath: "/mnt/artifacts",
    			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",
    		});
    	}
    }

    Any process in the sandbox can read, change, or delete every object under the prefix. Mount the narrowest prefix the job needs, and use access: "read-only" when the job only reads. The sandbox receives placeholder credentials, because s3fs requires a key pair. The mount works with enableInternet: false. For short-lived credentials, refer to Credentials.

    Calling mount() again with the same settings reuses the existing mount. Each new mount adds an outbound intercept, and unmount() does not remove it. An instance supports a limited number of intercepts. Give each job its own sandbox name instead of mounting a new prefix in the same instance.

  7. Add a method that runs a command in the mounted directory:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
    	// ...
    
    	async digest(job: string) {
    		await this.mountArtifacts(job);
    		const process = await this.container.exec(
    			// The command reads `input.txt` from R2 through the mount
    			// and writes `input.sha256` next to it
    			["sh", "-c", "sha256sum input.txt > input.sha256"],
    			{ cwd: "/mnt/artifacts" },
    		);
    		const output = await process.output();
    
    		return {
    			exitCode: output.exitCode,
    			stderr: new TextDecoder().decode(output.stderr),
    		};
    	}
    }

    The mount maps files to objects. Renames, locks, and permissions do not work as they do on a local disk. Write results that other systems read to the mount. Keep working files, such as dependencies and build output, on the sandbox disk. For more information, refer to Mounted file behavior.

  8. Add a method that unmounts the prefix and stops the sandbox:

    src/index.tsts
    async finish() {
    	if (!this.container.running) {
    		return;
    	}
    
    	await this.mounts.unmount("/mnt/artifacts");
    	await this.container.destroy();
    }

    If a process still uses the directory, unmount() throws SandboxS3MountError with code S3_MOUNT_BUSY. Stop the process, then call unmount() again.

  9. Add routes to your Worker that run a job and finish it:

    src/index.jsjs
    export default {
    	async fetch(request, env) {
    		const url = new URL(request.url);
    		const match =
    			/^\/jobs\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/digest)?$/.exec(
    				url.pathname,
    			);
    
    		if (!match) {
    			return new Response("Not found", { status: 404 });
    		}
    
    		const [, job, digest] = match;
    		const sandbox = env.MY_CONTAINER.getByName(job);
    
    		if (digest && request.method === "POST") {
    			return Response.json(await sandbox.digest(job));
    		}
    
    		if (!digest && request.method === "DELETE") {
    			await sandbox.finish();
    			return new Response(null, { status: 204 });
    		}
    
    		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 = /^\/jobs\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/digest)?$/.exec(url.pathname);
    
    		if (!match) {
    			return new Response("Not found", { status: 404 });
    		}
    
    		const [, job, digest] = match;
    		const sandbox = env.MY_CONTAINER.getByName(job);
    
    		if (digest && request.method === "POST") {
    			return Response.json(await sandbox.digest(job));
    		}
    
    		if (!digest && request.method === "DELETE") {
    			await sandbox.finish();
    			return new Response(null, { status: 204 });
    		}
    
    		return new Response("Method not allowed", { status: 405 });
    	},
    } satisfies ExportedHandler<Env>;

    Authenticate callers first, so other people cannot run jobs or read their results. For more information, refer to Sandbox security.

  10. Deploy your Worker:

    npx wrangler deploy
  11. Upload an input file for the job named ada, run the job, and read the result from R2. Replace the example hostname with the workers.dev URL that Wrangler prints:

    echo "artifact input" > input.txt
    npx wrangler r2 object put sandbox-artifacts/jobs/ada/input.txt --file input.txt --remote
    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/jobs/ada/digest --request POST
    npx wrangler r2 object get sandbox-artifacts/jobs/ada/input.sha256 --remote --pipe

    The job responds with {"exitCode":0,"stderr":""}, and R2 has the digest:

    <SHA256_DIGEST>  input.txt
  12. Unmount the prefix and stop the sandbox:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/jobs/ada --request DELETE

Develop locally

Under wrangler dev, the container runs in Docker on your machine, and S3Gateway runs in your local Worker. Point the mount at an S3-compatible server on your machine instead of R2. In this example, the server is MinIO ↗︎. FUSE needs Docker in a virtual machine, such as Docker Desktop, or rootless Linux Docker with /dev/fuse. For more information, refer to FUSE support.

  1. Start MinIO and create a bucket:

    docker run --detach --name minio --publish 9000:9000 minio/minio server /data
    docker exec minio sh -c "mc alias set local http://localhost:9000 minioadmin minioadmin && mc mb local/sandbox-artifacts"
  2. Create a .dev.vars file in your project root. Its values override the variables and secrets under wrangler dev:

    .dev.varstxt
    S3_ENDPOINT=http://localhost:9000
    S3_ACCESS_KEY_ID=minioadmin
    S3_SECRET_ACCESS_KEY=minioadmin
  3. Start the development server:

    npx wrangler dev
  4. Upload an input file, run the job, and read the result from MinIO:

    echo "artifact input" | docker exec --interactive minio mc pipe local/sandbox-artifacts/jobs/ada/input.txt
    curl http://localhost:8787/jobs/ada/digest --request POST
    docker exec minio mc cat local/sandbox-artifacts/jobs/ada/input.sha256

Was this helpful?