Skip to content
Storage / R2

Storage

R2

Use R2 bucket bindings with OpenOrange, backed by a local Rivet Actor or by an S3-compatible store.

Status: Supported. Runs on one Rivet Actor for each bucket, or on your S3-compatible store.

Configuration

OpenOrange reads the standard Wrangler R2 binding. No extra setup is needed:

{
  "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "uploads" }]
}

Like wrangler dev, OpenOrange names a local bucket by preview_bucket_name when it is set, then by bucket_name. Bindings with the same bucket name share data. OpenOrange ignores jurisdiction.

Store a bucket in S3 instead

By default, a Rivet Actor stores the bucket: on your machine under orange dev, and on your Rivet cluster after orange deploy. To keep a bucket in an S3-compatible store, set environment variables before you run orange dev. The variable names contain the bucket name from above in uppercase, with each character other than A-Z and 0-9 changed to _. For bucket_name: "uploads":

VariableRequiredValue
ORANGE_R2_UPLOADS_S3_ENDPOINTYesThe S3 endpoint URL. See Choose a provider. Setting it turns on S3 for this bucket.
ORANGE_R2_UPLOADS_S3_ACCESS_KEY_IDYesThe access key. Falls back to AWS_ACCESS_KEY_ID.
ORANGE_R2_UPLOADS_S3_SECRET_ACCESS_KEYYesThe secret key. Falls back to AWS_SECRET_ACCESS_KEY.
ORANGE_R2_UPLOADS_S3_BUCKETNoThe S3 bucket name. Defaults to bucket_name.
ORANGE_R2_UPLOADS_S3_REGIONNoThe S3 region. Falls back to AWS_REGION, then auto.
# .dev.vars
ORANGE_R2_UPLOADS_S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
ORANGE_R2_UPLOADS_S3_REGION=us-east-1
ORANGE_R2_UPLOADS_S3_ACCESS_KEY_ID=<access key ID>
ORANGE_R2_UPLOADS_S3_SECRET_ACCESS_KEY=<secret access key>

OpenOrange reads these variables from the shell environment, .dev.vars, or .env. A value from the shell wins. The S3 bucket must already exist. If the endpoint is set but a key is missing, the Worker does not start, and the error names the key variables to set.

Choose a provider

Provider..._S3_ENDPOINT..._S3_REGIONKeys
Amazon S3https://s3.<region>.amazonaws.comThe bucket’s region, such as us-east-1An IAM access key ID and secret access key
Cloudflare R2https://<account ID>.r2.cloudflarestorage.comauto (the default)The access key ID and secret access key of an R2 API token
Google Cloud Storagehttps://storage.googleapis.comauto (the default)An HMAC key: the access ID, which starts with GOOG, and the secret
Backblaze B2https://s3.<region>.backblazeb2.comThe region in the endpoint, such as us-west-004An application key ID and application key
MinIO that you runhttp://127.0.0.1:9000auto (the default)The MinIO user and password. See Run MinIO locally.

OpenOrange sends path-style requests, such as <endpoint>/<bucket>/<key>. A store that accepts only virtual-hosted requests does not work. For example, Tigris buckets created after February 19, 2025 accept only virtual-hosted requests.

Run MinIO locally

export ORANGE_R2_UPLOADS_S3_ENDPOINT=http://127.0.0.1:9000
export ORANGE_R2_UPLOADS_S3_ACCESS_KEY_ID=minioadmin
export ORANGE_R2_UPLOADS_S3_SECRET_ACCESS_KEY=minioadmin
orange dev

How it works

The binding implements the R2 Workers API on top of one of two storage backends:

  • head(), get(), put(), delete() with one key or an array, and list() with prefix, delimiter, cursor, startAfter, limit, and include.
  • onlyIf as an object or as Headers, and range as an object or as Headers. A failed condition makes get() return the object without a body and put() return null.
  • httpMetadata, customMetadata, and one of md5, sha1, sha256, sha384, or sha512 on put(). A checksum that does not match fails the put and keeps the stored object.
  • createMultipartUpload(), resumeMultipartUpload(), and uploadPart(), abort(), and complete() on the upload.
  • R2Object.writeHttpMetadata(), and arrayBuffer(), bytes(), text(), json(), and blob() on a body.

Argument checks, limits, and error messages follow workerd and the local R2 backend of wrangler dev. For example, a key longer than 1024 bytes rejects with put: The specified object name is not valid. (10020).

Default backend: a Rivet Actor per bucket

Each bucket is one internal Durable Object, which runs as a Rivet Actor with SQLite storage. Data persists across orange dev restarts in the project’s .open-orange directory. The bucket is the actor openorange:r2-bucket with the bucket name as its key, and the objects table lists the objects.

  • A put cuts the body into 512 KiB chunks and writes each chunk with its own request. The object becomes visible only when a final request commits it, so a reader never sees a partial or mixed object.
  • If the upload stops, for example because orange dev restarts, the previous object stays visible.
  • Chunks of overwritten, deleted, and abandoned objects are deleted 5 minutes later by an alarm.

S3 backend

Each call becomes one or more S3 requests. OpenOrange evaluates onlyIf and ranges with the same rules as the default backend. A conditional put reads the current object first, then writes with If-Match or If-None-Match so that a concurrent write makes the put return null. A put from a stream first writes the body to a temporary file, because S3 needs the length before the upload starts.

Conformance

An end-to-end orange dev test runs the same cases against both backends. The S3 run uses a real MinIO server. The cases cover:

  • Byte-equal round trips for 0 B, 1 B, 512 KiB ± 1 B, and 30 MiB, with MD5 ETags.
  • Failed onlyIf on get() and put(), as an object and as Headers.
  • Ranges that cross a chunk boundary, and an unsatisfiable range.
  • Two concurrent puts on one key while reads run.
  • Checksum mismatches, metadata, listing, and multipart uploads.
  • Invalid keys and ssecKey.

The default backend also has a restart test. The S3 backend also has a test for an S3 endpoint that is down. The upstream workerd R2 tests use a mock R2 server that the conformance runner does not configure.

Limits and deviations

These apply to both backends:

  • ssecKey is not supported yet. Calls with it reject with a NotSupportedError.
  • A put accepts a ReadableStream of any length. Cloudflare requires a known length.
  • list() always honors include, as with the r2_list_honor_include compatibility flag.
  • storageClass is stored as given and not checked.
  • There is no orange r2 command to manage objects from the CLI.

The default backend also has these limits:

  • One Rivet Actor stores each bucket, so Rivet’s storage limit for one actor bounds the bucket. Cloudflare buckets have no size limit.
  • Large objects are slow. On a development machine, a 30 MiB object takes about 1.5 seconds to write and 4.5 seconds to read.
  • A read that takes longer than 5 minutes can fail if the object is overwritten or deleted during the read.
  • Incomplete multipart uploads are kept until they are completed or aborted. Cloudflare aborts them after 7 days.

The S3 backend also has these limits:

  • version is the S3 version ID when bucket versioning is on. Otherwise, it is the ETag.
  • uploaded has one-second precision.
  • S3 returns custom metadata keys in lowercase. Orange keeps the original case of keys that have capital letters in one extra metadata entry, orange-r2-key-case, and restores it on reads. The entry counts toward the S3 2 KB metadata limit, and other S3 clients see it. Two keys that differ only in case cannot both be stored.
  • S3 stores a default content type when the put has none.
  • checksums returns MD5 for single-part objects, plus SHA-1 or SHA-256 when the put gave one. A SHA-384 or SHA-512 checksum is verified but not stored.
  • list() with include reads each listed object’s metadata with one more request.
  • Completing a multipart upload with no parts fails. Aborting an unknown upload succeeds.
  • Part ETags are the MD5 of the part.
  • A conditional put is atomic only if the store supports If-Match and If-None-Match on writes. AWS S3, MinIO, and R2 support them.

Compatibility

From the latest conformance run. Scores on the compatibility page; every binding on the status page.

R2

Pluggable r2_buckets

Runs on Actor SQLite by default, or any S3-compatible store per bucket.

Pick the backend with ORANGE_R2_<BUCKET>_S3_* variables. On the default backend, one Rivet Actor stores each bucket, so Rivet's storage limit for one actor bounds the bucket. SSE-C keys throw.

Works with

Locally: Actor storage, MinIO

Any S3-compatible store, set per bucket with ORANGE_R2_<BUCKET>_S3_*.

Scales by S3 or bucket. With S3 set, as far as your S3 store goes. Without it, one actor per bucket.

Recorded results

WorksR2Available.On an S3-compatible store per bucket, or in actor SQLite when none is set. In actor SQLite, Rivet's storage limit for one actor bounds a bucket. SSE-C keys throw. 1/1 pass
What to do
In production, set ORANGE_R2_<BUCKET>_S3_*.
Results
  • example: r2 Pass

In the feature matrix

Alternativer2_buckets (r2_bucket)Reads and writes objects in an R2 bucket.Each bucket_name is an Orange-internal SQLite Durable Object on Rivet Actors that stores bodies in 512 KiB chunks. head(), get(), put(), delete(), list(), conditional and range requests, checksums, and multipart uploads work, and objects persist across orange dev restarts. A bucket can use an S3-compatible store instead through ORANGE_R2_<BUCKET>_S3_* variables. 1/2 pass · 7 files
Notes
On the default backend, one Rivet Actor stores each bucket, so Rivet's storage limit for one actor bounds the bucket. ssecKey throws NotSupportedError. Puts accept streams of any length. The S3 backend restores custom metadata key case from an orange-r2-key-case metadata entry. Upstream r2-test fails because the conformance runner drops workerd's r2Bucket binding. See docs/modules/r2.mdx.
Tests
  • api-tests--r2-test › r2-test AssertionError [ERR_ASSERTION]: Expected values to be loosely deep-equal: | | { Fail · Behavior differs
  • api-tests--r2-write-http-metadata-validation-test › writeHttpMetadataValidation Pass
Source
src/config/load-wrangler.ts:813-820packages/runtime-node/src/runner.ts:526-539packages/runtime-node/src/bindings/r2/bucket.ts:166-294packages/runtime-node/src/bindings/r2/storage.ts:23-28packages/runtime-node/src/bindings/r2/storage.ts:192-737packages/runtime-node/src/bindings/r2/s3-backend.ts:302-627src/runtime/r2.test.ts:224-501