---
title: Cloudflare R2
description: Cloudflare R2 over the S3-compatible HTTP API. Auto-loads R2_* env vars or accepts an R2Bucket binding inside Workers.
sidebar:
  badge: Recommended
related:
  - /guides/nextjs-r2-file-upload
  - /guides/cloudflare-workers-r2-storage
  - /guides/r2-cors-presigned-url-errors
  - /guides/migrate-s3-to-r2
  - /guides/r2-upload-events-queues
  - /guides/s3-r2-mcp-server
---

:::tip[Recommended provider]
Cloudflare helps maintain Files SDK by [sponsoring its development](https://github.com/sponsors/haydenbleasel), so R2 is a recommended provider.
:::

## 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](#lightweight-fetch-client) need none of them - `files-sdk` alone is enough:

```package-install
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. If they're missing when the `"aws-sdk"` client runs, its first call rejects with a `FilesError` that names them.

```package-install
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](/docs/api/upload#progress-tracking) 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.

### Bundling for Workers

The adapter loads the `"aws-sdk"` client with `import()`, and Wrangler's bundler looks at those imports at build time whether or not they run. When the `@aws-sdk/*` packages aren't installed, it leaves the imports unresolved and the Worker builds with `files-sdk` alone. When they are installed - a hoisted monorepo counts - it bundles them even if the Worker only uses the binding or the fetch client, adding roughly 900&nbsp;KiB (about 160&nbsp;KiB gzipped). Leave them out of a Worker that doesn't pass `client: "aws-sdk"`.

:::note
Up to files-sdk 2.6.2, a Worker without the `@aws-sdk/*` packages failed `wrangler deploy` and `wrangler dev` with `Could not resolve "@aws-sdk/client-s3"` (and the same for `lib-storage` and both presigners), even on the binding path. Upgrade, or point the four packages at a stub module with Wrangler's [`alias`](https://developers.cloudflare.com/workers/wrangler/configuration/#bundling-issues) setting.
:::

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

```ts lineNumbers
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](https://github.com/mhart/aws4fetch) (~2.5&nbsp;KB gzipped, Web Crypto only). It needs no `@aws-sdk/*` packages, and without them installed none are bundled ([details](#bundling-for-workers)) - ideal for Cloudflare Workers and other edge runtimes where the AWS SDK's ~500&nbsp;KB defeats the point of web-standard tooling.

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

The fetch client covers `upload`, `download` (including [ranges](/docs/api/download#byte-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&nbsp;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`](/docs/adapters/s3-fetch).

## Options

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

### HTTP mode

<AutoTypeTable
  path="../../packages/files-sdk/src/r2/index.ts"
  name="R2HttpOptions"
/>

### Binding mode (inside a Worker)

<AutoTypeTable
  path="../../packages/files-sdk/src/r2/index.ts"
  name="R2BindingOptions"
/>

The binding's `put()` needs 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` or `response.body` with a `Content-Length`, or the readable side of a [`FixedLengthStream`](https://developers.cloudflare.com/workers/runtime-apis/streams/transformstream/#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](https://github.com/mhart/aws4fetch) (Web Crypto only), so neither the binding path nor hybrid mode needs the `@aws-sdk/*` packages - leave them uninstalled to keep them out of the Worker bundle ([details](#bundling-for-workers)).

```ts lineNumbers
// 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()`](/docs/api/signed-upload-url) 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 an `Unsupported` error (and `capabilities.signedUpload.maxSize` is `false`) rather than handing back a POST form that R2 would reject with `501 Not Implemented` at upload time. A positive `minSize` needs the same policy, so it throws too (`minSize: 0` is fine).

```ts lineNumbers
// ✅ 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](/docs/conditional-operations) 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 `contentType` 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` and a positive `minSize` throw (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 `contentType` 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` / `minSize` 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 `Blob`s 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` and a positive `minSize` throw (no `content-length-range` policy; a presigned POST would 501 at upload time). Enforce upload caps at your application gateway instead. |
