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.
- 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.
- An R2 bucket, and an R2 API token with the Object Read & Write permission for that bucket only. The examples use a bucket named
sandbox-artifacts.
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 -
Create a
Dockerfilein your project root with FUSE,s3fs, and thesandbox-shimbinary thatS3Mountuses: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/sandboxtag that matches the@cloudflare/sandboxversion you installed. -
In
wrangler.jsonc, turn onnodejs_compat, add the endpoint and name of the bucket as variables, declare the credentials as secrets, and build theDockerfileas 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.requiredadds the secrets to the generated types and makeswrangler deployfail when they are missing. To mount a bucket from another S3-compatible service, changeS3_ENDPOINTandS3_BUCKET, and use the credentials for that service. -
Generate types, then add the Access Key ID and Secret Access Key from the R2 API token as secrets:
npx wrangler typesyarn wrangler typespnpm wrangler typesnpx wrangler secret put S3_ACCESS_KEY_IDyarn wrangler secret put S3_ACCESS_KEY_IDpnpm wrangler secret put S3_ACCESS_KEY_IDnpx wrangler secret put S3_SECRET_ACCESS_KEYyarn wrangler secret put S3_SECRET_ACCESS_KEYpnpm wrangler secret put S3_SECRET_ACCESS_KEY -
Import
S3Mount, and exportS3Gatewayfrom 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";s3fsin the sandbox sends each storage request to an address that the container intercepts. The intercept delivers the request toS3Gatewayin your Worker.S3Gatewaychecks 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. -
Add a constructor to your Durable Object. It creates one
S3Mountobject, 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.containerstays the same object for as long as the Durable Object runs, so oneS3Mountobject 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, becauses3fsrequires a key pair. The mount works withenableInternet: 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, andunmount()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. -
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.
-
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()throwsSandboxS3MountErrorwith codeS3_MOUNT_BUSY. Stop the process, then callunmount()again. -
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.
-
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Upload an input file for the job named
ada, run the job, and read the result from R2. Replace the example hostname with theworkers.devURL 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 --pipeThe job responds with
{"exitCode":0,"stderr":""}, and R2 has the digest:<SHA256_DIGEST> input.txt -
Unmount the prefix and stop the sandbox:
curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/jobs/ada --request DELETE
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.
-
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" -
Create a
.dev.varsfile in your project root. Its values override the variables and secrets underwrangler dev:.dev.varstxt S3_ENDPOINT=http://localhost:9000 S3_ACCESS_KEY_ID=minioadmin S3_SECRET_ACCESS_KEY=minioadmin -
Start the development server:
npx wrangler devyarn wrangler devpnpm wrangler dev -
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
- S3Mount API: every option and error.
- Artifact workspace example ↗︎: a deployable Worker that mounts a prefix for each sandbox and processes a file in it.
- Save and restore a sandbox with snapshots: keep files on the sandbox disk between instances. Snapshots do not include mounted directories.
- S3 API compatibility: the S3 operations that R2 supports.