Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Cloudflare R2

Cloudflare R2 over the S3-compatible HTTP API. Auto-loads R2_* env vars or accepts an R2Bucket binding inside Workers.

Installation

The @aws-sdk/* packages are only needed for the "aws-sdk" HTTP client (the default outside Cloudflare Workers). The Workers binding path, hybrid signing, and the lightweight fetch client need none of them - files-sdk alone is enough:

npm install files-sdk
pnpm add files-sdk
yarn add files-sdk
bun add files-sdk
nub add files-sdk
aube add files-sdk

For the "aws-sdk" HTTP client, @aws-sdk/client-s3, @aws-sdk/s3-presigned-post, and @aws-sdk/s3-request-presigner are optional peer dependencies - install alongside the SDK so the adapter’s imports resolve at runtime.

npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
pnpm add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
yarn add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
bun add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
nub add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
aube add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner

Over the HTTP API with the "aws-sdk" client, upload also needs the optional @aws-sdk/lib-storage package to report true byte-level progress via onProgress, to use multipart, or to upload a ReadableStream body of unknown length. It’s only loaded when upload uses onProgress, multipart, or a ReadableStream body of unknown length. Under the Workers R2Bucket binding and the fetch client the SDK reports progress generically instead — byte-level for stream bodies, start and finish for buffered ones.

Usage

Cloudflare R2 over the S3-compatible HTTP API. Auto-loads from R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY. Inside Cloudflare Workers you can pass an R2Bucket binding directly instead.

import { Files } from "files-sdk";
import { r2 } from "files-sdk/r2";

const files = new Files({
  adapter: r2({
    bucket: "uploads",
    accountId: process.env.R2_ACCOUNT_ID!,
    // accessKeyId / secretAccessKey auto-loaded
    // from R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY
  }),
});

publicBaseUrl - optional, an r2.dev subdomain or custom domain bound to the bucket. When set, url() returns `${publicBaseUrl}/${key}` and skips signing.

Lightweight fetch client

Pass client: "fetch" to swap the @aws-sdk/* stack for a SigV4-signed fetch engine built on aws4fetch (~2.5 KB gzipped, Web Crypto only). No @aws-sdk/* packages are installed or bundled - ideal for Cloudflare Workers and other edge runtimes where the AWS SDK’s ~500 KB defeats the point of web-standard tooling.

const files = new Files({
  adapter: r2({
    bucket: "uploads",
    accountId: process.env.R2_ACCOUNT_ID!,
    client: "fetch",
  }),
});

The fetch client covers upload, download (including ranges), head, exists, delete, list (including delimiter folding), server-side copy, presigned url(), and signedUploadUrl(). Trade-offs against the "aws-sdk" client:

  • ReadableStream bodies are buffered in memory before a single PUT (a lone PUT needs a Content-Length, and single-request uploads cap at 5 GB on R2).
  • multipart and resumable (control) uploads throw instead of engaging the S3 multipart API.
  • Bulk deletes fan out as per-key delete() calls instead of batched DeleteObjects requests. On Workers each call is a subrequest, so chunk large bulk deletes to stay under the per-invocation subrequest cap.
  • Keys containing . or .. path segments are rejected - URL normalization would silently sign a request for a different key.
  • Byte-level onProgress reporting falls back to the SDK’s generic reporting.
  • files.raw is the aws4fetch AwsClient, not an S3Client.

Default inside Cloudflare Workers

Inside Cloudflare Workers the fetch client is the default. The "aws-sdk" client’s XML parsing needs a DOMParser, which workerd doesn’t provide, so it fails at runtime on the first list or error-body parse - long after construction. Detection uses navigator.userAgent === "Cloudflare-Workers", or the workerd-only WebSocketPair global on compatibility dates where navigator is disabled. Two things keep the "aws-sdk" default even in a Worker: an explicit client: "aws-sdk", and a DOMParser on the global (the usual polyfill workaround), since the SDK works once that is present.

The swap is silent, so if a Worker relied on the "aws-sdk" client working - multipart or resumable uploads, batched bulk deletes, files.raw as an S3Client - the trade-offs above now apply to it. Pass client: "aws-sdk" to keep the old engine. Other edge runtimes with the same missing DOMParser (Vercel Edge, for example) are not auto-detected; pass client: "fetch" there explicitly.

For any other S3-compatible endpoint on this engine - AWS S3, MinIO, Tigris, … - use s3Fetch() from files-sdk/s3-fetch.

Options

R2AdapterOptions is a union of two shapes depending on whether you have a Workers R2Bucket binding available.

HTTP mode

PropType
bucketstring

R2 bucket name.

Typestring
accountId?string

Cloudflare account ID. Falls back to `R2_ACCOUNT_ID` env var; required if no env var is set — unless an explicit `endpoint` is passed, which makes `accountId` unnecessary.

Typestring
accessKeyId?string

R2 access key ID. Falls back to `R2_ACCESS_KEY_ID` env var; required if no env var is set.

Typestring
secretAccessKey?string

R2 secret access key. Falls back to `R2_SECRET_ACCESS_KEY` env var; required if no env var is set.

Typestring
publicBaseUrl?string

Origin used to build URLs from `url()` — typically an `r2.dev` subdomain or a custom domain bound to the bucket. When set, `url()` returns `${publicBaseUrl}/${key}` and skips signing. When unset, `url()` returns a presigned GetObject URL (default expiry: 1 hour).

Typestring
defaultUrlExpiresIn?number

Default expiry, in seconds, for `url()` when `publicBaseUrl` is unset. Defaults to 3600.

Typenumber
endpoint?string

Override the S3 API endpoint. Defaults to `https://<accountId>.r2.cloudflarestorage.com`. Set it for jurisdiction buckets, which live on their own hostnames (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`), or to point the adapter at an S3-compatible stand-in (MinIO, LocalStack) in tests. When set, `accountId` is not required.

Typestring
client?"aws-sdk" | "fetch"

Which HTTP engine backs the adapter. Defaults to `"aws-sdk"`, except on Cloudflare Workers (detected via `navigator.userAgent === "Cloudflare-Workers"`, or the workerd-only `WebSocketPair` global when `navigator` is disabled), where it defaults to `"fetch"` — the aws-sdk engine's XML parsing needs `DOMParser`, which workerd doesn't provide. A Worker that polyfills `DOMParser` keeps the `"aws-sdk"` default. Note the `"fetch"` engine's narrower surface below before relying on the default in a Worker. - `"aws-sdk"`: `@aws-sdk/client-s3` — the full surface, including multipart/resumable uploads, byte-level upload progress, and batched `deleteMany`. Requires the `@aws-sdk/*` optional peer dependencies. Loaded lazily on first use. - `"fetch"`: SigV4-signed `fetch` via `aws4fetch` (~2.5 KB) — no `@aws-sdk/*` install needed, ideal for Workers and other edge runtimes. Covers upload, download (+ ranges), head, exists, delete, list (+ delimiter), server-side copy, presigned `url()`, and `signedUploadUrl()`. Trade-offs: `ReadableStream` bodies are buffered before the single PUT, `multipart`/`control` uploads throw, bulk deletes fan out per-key instead of batching, keys with `.`/`..` segments are rejected, and `raw` is an aws4fetch `AwsClient`. For a generic S3-compatible endpoint on the same engine, see `s3Fetch()` from `files-sdk/s3-fetch`.

Type"aws-sdk" | "fetch"
fetch?(request: Request) => Promise<Response>

Override the `fetch` implementation used by the `"fetch"` client — for tests, or runtimes that hand out a bound/instrumented fetch. Defaults to `globalThis.fetch`. Ignored by the `"aws-sdk"` client.

Type(request: Request) => Promise<Response>

Binding mode (inside a Worker)

PropType
bindingR2Bucket

Workers `R2Bucket` binding. Reads and writes go through the binding. The binding's `put()` needs to know an upload's length up front. Strings, bytes, `Blob`s, and `File`s always work. A `ReadableStream` works only when workerd knows its length: a `request.body` / `response.body` with a `Content-Length`, or the readable side of a `FixedLengthStream`. workerd rejects a stream of unknown length (one you built yourself, or a `TransformStream`'s output), so wrap it in a `FixedLengthStream` or buffer it first.

TypeR2Bucket
bucket?string

R2 bucket name. Required for hybrid signing — it names the bucket in the signed URL path that `url()` / `signedUploadUrl()` produce. Without it, the HTTP credentials below are ignored and signing throws with guidance.

Typestring
publicBaseUrl?string

Origin used to build URLs from `url()` — typically an `r2.dev` subdomain or a custom domain bound to the bucket. Without this (and without HTTP credentials below), `url()` throws because a Workers binding has no signing primitive.

Typestring
accountId?string

Hybrid mode: Cloudflare account ID, used alongside `accessKeyId` + `secretAccessKey` so `url()` and `signedUploadUrl()` can fall back to an S3-compatible SigV4 signer (aws4fetch — no `@aws-sdk/*` install needed) instead of throwing. Reads and writes still go through the binding so they stay intra-Worker (no egress fees). Useful for Workers that need browser-facing presigned URLs without giving up the binding's I/O performance. An explicit `endpoint` can stand in for `accountId`, which only feeds the default signing hostname.

Typestring
accessKeyId?string

Hybrid mode: R2 access key ID. See `accountId`.

Typestring
secretAccessKey?string

Hybrid mode: R2 secret access key. See `accountId`.

Typestring
defaultUrlExpiresIn?number

Default expiry, in seconds, for `url()` when it falls back to HTTP signing (hybrid mode without `publicBaseUrl`). Defaults to 3600.

Typenumber
endpoint?string

Hybrid mode: override the S3 API endpoint used for signing. Defaults to `https://<accountId>.r2.cloudflarestorage.com`. Set it for jurisdiction buckets, which live on their own hostnames (e.g. `https://<accountId>.eu.r2.cloudflarestorage.com`). When set, `accountId` is not required for hybrid signing.

Typestring

The binding’s put() needs an upload’s length up front. Strings, bytes, Blobs, and Files always work. A ReadableStream works only when workerd knows its length: a request.body or response.body with a Content-Length, or the readable side of a FixedLengthStream. workerd rejects a stream of unknown length (one you built yourself, or a TransformStream’s output), so wrap it in a FixedLengthStream or buffer it first.

Hybrid: binding + HTTP credentials

Inside a Worker, you can pass both a binding and HTTP credentials (bucket, accountId or endpoint, accessKeyId, and secretAccessKey). Reads and writes go through the binding (no egress, no extra round trip); url() and signedUploadUrl() route through an S3-compatible SigV4 signer because a Worker binding has no signing primitive. Hybrid signing runs on aws4fetch (Web Crypto only), so neither the binding path nor hybrid mode ever pulls @aws-sdk/* packages into the Worker bundle.

// Inside a Cloudflare Worker. The binding handles uploads/downloads
// (intra-Worker, no egress fees). The HTTP credentials let url() and
// signedUploadUrl() sign presigned URLs the binding alone can't produce.
const files = new Files({
  adapter: r2({
    binding: env.UPLOADS,
    bucket: "uploads",
    accountId: env.R2_ACCOUNT_ID,
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
  }),
});

Signed uploads and maxSize

signedUploadUrl() returns a presigned PUT URL. Unlike S3, R2 does not implement the S3 POST Object API, so it has no content-length-range policy to enforce an upload size cap at the bucket. Passing maxSize throws a Provider error rather than handing back a POST form that R2 would reject with 501 Not Implemented at upload time.

// ✅ presigned PUT — the browser uploads with fetch(url, { method: "PUT", body: file })
const upload = await files.signedUploadUrl("avatars/abc.png", {
  expiresIn: 60,
  contentType: "image/png",
});

// ❌ throws: R2 has no server-enforced size limit
await files.signedUploadUrl("avatars/abc.png", {
  expiresIn: 60,
  maxSize: 5_000_000,
});

To cap upload sizes on R2, enforce the limit at your application gateway before issuing the URL.

Conditional operations

R2 does not advertise the SDK’s provider-native conditional operation contract in this release. That includes the AWS-SDK HTTP path: configuring an S3-compatible endpoint does not inherit AWS S3’s capability claims. Conditional calls fail before R2 I/O.

Compatibility

HTTP mode

Method Status Notes
upload ✅
download ✅
delete ✅
list ⚠️ The S3 list API returns no per-object Content-Type, so type is inferred from the key’s extension (application/octet-stream when unknown). Use head() for the stored value.
search ✅
head ✅
exists ✅
copy ✅
url ✅
signedUploadUrl ⚠️ PUT URL only - Cloudflare R2 doesn’t implement the S3 POST Object API, so maxSize throws (no content-length-range policy; a presigned POST would 501 at upload time). Enforce upload caps at your application gateway instead.

HTTP mode (client: "fetch")

Method Status Notes
upload ⚠️ Single PUT - ReadableStream bodies are buffered in memory first, and multipart / resumable control uploads throw. Multipart needs the "aws-sdk" client, which runs on Workers only with a DOMParser polyfill.
download ✅
delete ✅ Bulk deletes fan out per key (no batched DeleteObjects).
list ⚠️ The S3 list API returns no per-object Content-Type, so type is inferred from the key’s extension (application/octet-stream when unknown). Use head() for the stored value.
search ✅
head ✅
exists ✅
copy ✅
url ✅
signedUploadUrl ⚠️ PUT URL only - same maxSize limitation as above.

Binding mode

Method Status Notes
upload ⚠️ A ReadableStream body needs a known length (a request/response body with Content-Length, or a FixedLengthStream); workerd rejects one of unknown length. Strings, bytes, and Blobs always work.
download ✅
delete ✅
list ✅
search ✅
head ✅
exists ✅
copy ⚠️ Read-then-write - Workers bindings have no native copy command, so the source is fetched and re-uploaded. Not server-side atomic; concurrent writes to the source between the get and put are not detected.
url ❌ Throws unless publicBaseUrl is set on the adapter (an r2.dev subdomain or a custom domain). For a presigned URL from a Worker, switch to hybrid mode by also passing bucket + accountId (or endpoint) + accessKeyId + secretAccessKey.
signedUploadUrl ❌ Workers bindings can’t sign uploads - the secret access key is not available to the runtime. Use hybrid mode (binding + HTTP credentials) to issue presigned upload URLs.

Hybrid mode

Method Status Notes
upload ⚠️ Goes through the binding, so a ReadableStream body needs a known length, as in binding mode.
download ✅
delete ✅
list ✅
search ✅
head ✅
exists ✅
copy ⚠️ Read-then-write - copy goes through the binding (no native copy command on Workers).
url ✅
signedUploadUrl ⚠️ PUT URL only - signing routes through the HTTP signer. R2 doesn’t implement the S3 POST Object API, so maxSize throws (no content-length-range policy; a presigned POST would 501 at upload time). Enforce upload caps at your application gateway instead.

Was this page helpful?