R2

Demos and architectures

import { GlossaryTooltip, ResourcesBySelector } from "~/components";

Learn how you can use R2 within your existing application and architecture.

Reference architectures

Explore the following reference architectures that use R2:

<ResourcesBySelector types={[ "reference-architecture", "design-guide", "reference-architecture-diagram", ]} products={["r2"]} />

How R2 works

import { Render, LinkCard } from "~/components";

Cloudflare R2 is an S3-compatible object storage service with no egress fees, built on Cloudflare's global network. It is strongly consistent and designed for high data durability.

R2 is ideal for storing and serving unstructured data that needs to be accessed frequently over the internet, without incurring egress fees. It's a good fit for workloads like serving web assets, training AI models, and managing user-generated content.

Architecture

R2's architecture is composed of multiple components:

R2 Architecture

R2 supports multiple client interfaces including Cloudflare Workers Binding, S3-compatible API, and a REST API that powers the Cloudflare Dashboard and Wrangler CLI. All requests are routed through the R2 Gateway, which coordinates with the Metadata Service and Distributed Storage Infrastructure to retrieve the object data.

Write data to R2

When a write request (e.g. uploading an object) is made to R2, the following sequence occurs:

  1. Request handling: The request is received by the R2 Gateway at the edge, close to the user, where it is authenticated.

  2. Encryption and routing: The Gateway reaches out to the Metadata Service to retrieve the encryption key and determines which storage cluster to write the encrypted data to within the location set for the bucket.

  3. Writing to storage: The encrypted data is written and stored in the distributed storage infrastructure, and replicated within the region (e.g. ENAM) for durability.

  4. Metadata commit: Finally, the Metadata Service commits the object's metadata, making it visible in subsequent reads. Only after this commit is an HTTP 200 success response sent to the client, preventing unacknowledged writes.

Write data to R2

Read data from R2

When a read request (e.g. fetching an object) is made to R2, the following sequence occurs:

  1. Request handling: The request is received by the R2 Gateway at the edge, close to the user, where it is authenticated.

  2. Metadata lookup: The Gateway asks the Metadata Service for the object metadata.

  3. Reading the object: The Gateway attempts to retrieve the encrypted object from the tiered read cache. If it's not available, it retrieves the object from one of the distributed storage data centers within the region that holds the object data.

  4. Serving to client: The object is decrypted and served to the user.

Read data to R2

Performance

The performance of your operations can be influenced by factors such as the bucket's geographical location, request origin, and access patterns.

To optimize upload performance for cross-region requests, enable Local Uploads on your bucket.

To optimize read performance, enable Cloudflare Cache when using a custom domain. When caching is enabled, read requests can bypass the R2 Gateway and be served directly from Cloudflare's edge cache, reducing latency. Note that cached data may not reflect the latest version immediately.

Read data to R2 with Cloudflare Cache

Learn more

Cloudflare R2

import { CardGrid, Description, Feature, LinkButton, LinkTitleCard, Plan, RelatedProduct, } from "~/components";

Object storage for all your data.

Cloudflare R2 Storage allows developers to store large amounts of unstructured data without the costly egress bandwidth fees associated with typical cloud storage services.

You can use R2 for multiple scenarios, including but not limited to:

Get started Browse the examples

Features

Location Hints are optional parameters you can provide during bucket creation to indicate the primary geographical location you expect data will be accessed from.

Configure CORS to interact with objects in your bucket and configure policies on your bucket.

Public buckets expose the contents of your R2 bucket directly to the Internet.

Create bucket scoped tokens for granular control over who can access your data.


A managed Apache Iceberg data catalog built directly into your R2 bucket, so you can turn a bucket into a data warehouse or lakehouse queryable by engines like Spark, Snowflake, and Basin SQL.

A serverless execution environment that allows you to create entirely new applications or augment existing ones without configuring or maintaining infrastructure.

Upload, store, encode, and deliver live and on-demand video with one API, without configuring or maintaining infrastructure.

A suite of products tailored to your image-processing needs.


More resources

Understand pricing for free and paid tier rates.

<LinkTitleCard title="Discord" href="https://discord.cloudflare.com" icon="discord"

&#x20;Ask questions, show off what you are building, and discuss the platform
with other developers.
Learn about product announcements, new tutorials, and what is new in Cloudflare Workers.

Pricing

R2 charges based on the total volume of data stored, along with two classes of operations on that data:

  1. Class A operations which are more expensive and tend to mutate state.
  2. Class B operations which tend to read existing state.

For the Infrequent Access storage class, data retrieval fees apply. There are no charges for egress bandwidth for any storage class.

All included usage is on a monthly basis.

:::note

To learn about potential cost savings from using R2, refer to the R2 pricing calculator.

:::

R2 pricing

Standard storage Infrequent Access storage
Storage $0.015 / GB-month $0.01 / GB-month
Class A Operations $4.50 / million requests $9.00 / million requests
Class B Operations $0.36 / million requests $0.90 / million requests
Data Retrieval (processing) None $0.01 / GB
Egress (data transfer to Internet) Free [^1] Free [^1]
Buckets per account First 100,000 included + $0.10 per additional bucket per month First 100,000 included + $0.10 per additional bucket per month

The bucket allowance is shared across all storage classes and applies regardless of your Workers plan. Refer to R2 limits for bucket creation limits and how to request a limit increase.

:::caution[Billable unit rounding] Cloudflare rounds up your usage to the next billing unit.

For example:

Free tier

You can use the following amount of storage and operations each month for free.

Free
Storage 10 GB-month / month
Class A Operations 1 million requests / month
Class B Operations 10 million requests / month
Egress (data transfer to Internet) Free [^1]

:::caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. :::

Storage usage

Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the peak storage per day over a billing period (30 days).

For example:

For objects stored in Infrequent Access storage, you will be charged for the object for the minimum storage duration even if the object was deleted or moved before the duration specified.

Class A operations

Class A Operations include ListBuckets, PutBucket, ListObjects, PutObject, CopyObject, CompleteMultipartUpload, CreateMultipartUpload, LifecycleStorageTierTransition, ListMultipartUploads, UploadPart, UploadPartCopy, ListParts, PutBucketEncryption, PutBucketCors and PutBucketLifecycleConfiguration.

Class B operations

Class B Operations include HeadBucket, HeadObject, GetObject, UsageSummary, GetBucketEncryption, GetBucketLocation, GetBucketCors and GetBucketLifecycleConfiguration.

Free operations

Free operations include DeleteObject, DeleteBucket and AbortMultipartUpload.

Data retrieval

Data retrieval fees apply when you access or retrieve data from the Infrequent Access storage class. This includes any time objects are read or copied.

Minimum storage duration

For objects stored in Infrequent Access storage, you will be charged for the object for the minimum storage duration even if the object was deleted, moved, or replaced before the specified duration.

Storage class Minimum storage duration
Standard storage None
Infrequent Access storage 30 days

Basin Catalog pricing

Basin Catalog charges for catalog operations and compaction data processed, in addition to standard R2 storage and operations. For full details, refer to Basin Catalog pricing.

Data migration pricing

Super Slurper

Super Slurper is free to use. You are only charged for the Class A operations that Super Slurper makes to your R2 bucket. Objects with sizes < 100MiB are uploaded to R2 in a single Class A operation. Larger objects use multipart uploads to increase transfer success rates and will perform multiple Class A operations. Note that your source bucket might incur additional charges as Super Slurper copies objects over to R2.

Once migration completes, you are charged for storage & Class A/B operations as described in previous sections.

Sippy

Sippy is free to use. You are only charged for the operations Sippy makes to your R2 bucket. If a requested object is not present in R2, Sippy will copy it over from your source bucket. Objects with sizes < 200MiB are uploaded to R2 in a single Class A operation. Larger objects use multipart uploads to increase transfer success rates, and will perform multiple Class A operations. Note that your source bucket might incur additional charges as Sippy copies objects over to R2.

As objects are migrated to R2, they are served from R2, and you are charged for storage & Class A/B operations as described in previous sections.

Pricing calculator

To learn about potential cost savings from using R2, refer to the R2 pricing calculator.

R2 billing examples

Standard storage example

If a user writes 1,000 objects in R2 Standard storage for 1 month with an average size of 1 GB and reads each object 1,000 times during the month, the estimated cost for the month would be:

Usage Free Tier Billable Quantity Price
Storage (1,000 objects) * (1 GB per object) = 1,000 GB-months 10 GB-months 990 GB-months $14.85
Class A Operations (1,000 objects) * (1 write per object) = 1,000 writes 1 million 0 $0.00
Class B Operations (1,000 objects) * (1,000 reads per object) = 1 million reads 10 million 0 $0.00
Data retrieval (processing) (1,000 objects) * (1 GB per object) = 1,000 GB NA None $0.00
TOTAL $14.85

Infrequent access example

If a user writes 1,000 objects in R2 Infrequent Access storage with an average size of 1 GB, stores them for 5 days, and then deletes them (delete operations are free), and during those 5 days each object is read 1,000 times, the estimated cost for the month would be:

Usage Free Tier Billable Quantity Price
Storage (1,000 objects) * (1 GB per object) = 1,000 GB-months NA 1,000 GB-months $10.00
Class A Operations (1,000 objects) * (1 write per object) = 1,000 writes NA 1,000 $9.00
Class B Operations (1,000 objects) * (1,000 reads per object) = 1 million reads NA 1 million $0.90
Data retrieval (processing) (1,000 objects) * (1 GB per object) = 1,000 GB NA 1,000 GB $10.00
TOTAL $29.90

Note that the minimal storage duration for infrequent access storage is 30 days, which means the billable quantity is 1,000 GB-months, rather than 167 GB-months.

Asset hosting

If a user writes 100,000 files with an average size of 100 KB object and reads 10,000,000 objects per day, the estimated cost in a month would be:

Usage Free Tier Billable Quantity Price
Storage (100,000 objects) * (100KB per object) 10 GB-months 0 GB-months $0.00
Class A Operations (100,000 writes) 1 million 0 $0.00
Class B Operations (10,000,000 reads per day) * (30 days) 10 million 290,000,000 $104.40
TOTAL $104.40

Cloudflare billing policy

To learn more about how usage is billed, refer to Cloudflare Billing Policy.

Frequently asked questions

Will I be charged for failed requests to my R2 bucket?

Requests that fail during authentication or authorization are not charged. This includes:

Some requests that pass authentication and authorization but fail for other reasons are counted and billed according to the operation's class. For example:

[^1]: Egressing directly from R2, including via the Workers API, S3 API, and r2.dev domains does not incur data transfer (egress) charges and is free. If you connect other metered services to an R2 bucket, you may be charged by those services.

Videos

import { CardGrid, LinkCard } from "~/components";

Error codes

This page documents error codes returned by R2 when using the Workers API or the S3-compatible API, along with recommended fixes to help with troubleshooting.

How errors are returned

For the Workers API, R2 operations throw exceptions that you can catch. The error code is included at the end of the message property:

try {
  await env.MY_BUCKET.put("my-key", data, { customMetadata: largeMetadata });
} catch (error) {
  console.error(error.message);
  // "put: Your metadata headers exceed the maximum allowed metadata size. (10012)"
}

For the S3-compatible API, errors are returned as XML in the response body:

<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>NoSuchKey</Code>
  <Message>The specified key does not exist.</Message>
</Error>

Error code reference

Authentication and authorization errors

Error Code S3 Code HTTP Status Details Recommended Fix
10002 Unauthorized 401 Missing or invalid authentication credentials. Verify your API token or access key credentials are correct and have not expired.
10003 AccessDenied 403 Insufficient permissions for the requested operation. Check that your API token has the required permissions for the bucket and operation.
10018 ExpiredRequest 403 Presigned URL or request signature has expired. Regenerate the presigned URL or signature.
10035 SignatureDoesNotMatch 403 Request signature does not match calculated signature. Verify your secret key and signing algorithm. Check for URL encoding issues.
10042 NotEntitled 403 Account not entitled to this feature. Ensure your account has an R2 subscription.

Bucket errors

Error Code S3 Code HTTP Status Details Recommended Fix
10005 InvalidBucketName 400 Bucket name does not meet naming requirements. Bucket names must be 3-63 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric.
10006 NoSuchBucket 404 The specified bucket does not exist. Verify the bucket name is correct and the bucket exists in your account.
10008 BucketNotEmpty 409 Cannot delete bucket that contains objects. Delete all objects in the bucket before deleting the bucket.
10009 TooManyBuckets 400 Account bucket limit exceeded (default: 1,000,000 buckets). Request a limit increase via the Limits Increase Request Form.
10073 BucketConflict 409 Bucket name already exists. Choose a different bucket name. Bucket names must be unique within your account.

Object errors

Error Code S3 Code HTTP Status Details Recommended Fix
10007 NoSuchKey 404 The specified object key does not exist. For the Workers API, get() and head() return null instead of throwing. Verify the object key is correct and the object has not been deleted.
10020 InvalidObjectName 400 Object key contains invalid characters or is too long. Use valid UTF-8 characters. Maximum key length is 1024 bytes.
100100 EntityTooLarge 400 Object exceeds maximum size (5 GiB for single upload, 5 TiB for multipart). Use multipart upload for objects larger than 5 GiB. Maximum object size is 5 TiB.
10012 MetadataTooLarge 400 Custom metadata exceeds the 8,192 byte limit. Reduce custom metadata size. Maximum is 8,192 bytes total for all custom metadata.
10069 ObjectLockedByBucketPolicy 409 Object is protected by a bucket lock rule and cannot be modified or deleted. If a conditional header on the object being written would also fail, this error is returned instead of PreconditionFailed. When CopyObject copies to a different key, changes the storage class, or uses SSE-C, source conditions (x-amz-copy-source-if-*) are evaluated before the destination lock check and return 412. When an object is copied onto itself, the lock check runs first and returns 409. Wait for the retention period to expire. Refer to bucket locks.

Upload and request errors

Error Code S3 Code HTTP Status Details Recommended Fix
10033 MissingContentLength 411 Content-Length header required but missing. Include the Content-Length header in PUT/POST requests.
10013 IncompleteBody 400 Request body terminated before expected Content-Length. Ensure the full request body is sent. Check for network interruptions or client timeouts.
10014 InvalidDigest 400 Checksum header format is malformed. Ensure checksums are properly encoded (base64 for SHA/CRC checksums).
10037 BadDigest 400 Provided checksum does not match the uploaded content. Verify data integrity and retry the upload.
10039 InvalidRange 416 Requested byte range is not satisfiable. Ensure the range start is less than object size. Check Range header format.
10031 PreconditionFailed 412 Conditional headers (If-Match, If-Unmodified-Since, etc.) were not satisfied. Object's ETag or modification time does not match your condition. Refetch and retry. Refer to conditional operations.

Multipart upload errors

Error Code S3 Code HTTP Status Details Recommended Fix
10011 EntityTooSmall 400 Multipart part is below minimum size (5 MiB), except for the last part. Ensure each part (except the last) is at least 5 MiB.
10024 NoSuchUpload 404 Multipart upload does not exist or was aborted. Verify the uploadId is correct. By default, incomplete multipart uploads expire after 7 days. Refer to object lifecycles.
10025 InvalidPart 400 One or more parts could not be found when completing the upload. Verify each part was uploaded successfully and use the exact ETag returned from UploadPart.
10048 InvalidPart 400 All non-trailing parts must have the same size. Ensure all parts except the last have identical sizes. R2 requires uniform part sizes for multipart uploads.

Service errors

Error Code S3 Code HTTP Status Details Recommended Fix
10001 InternalError 500 An internal error occurred. Retry the request. If persistent, check Cloudflare Status or contact support.
10043 ServiceUnavailable 503 Service is temporarily unavailable. Retry with exponential backoff. Check Cloudflare Status.
10054 ClientDisconnect 400 Client disconnected before request completed. Check network connectivity and retry.
10058 TooManyRequests 429 Rate limit exceeded. Often caused by multiple concurrent requests to the same object key (limit: 1 write/second per key). Check if multiple clients are accessing the same object key. See R2 limits.

API

import { DirectoryListing } from "~/components";

R2 provides three API surfaces for interacting with your data:

Authentication

import { DashButton } from "~/components";

You can generate an API token to serve as the Access Key for usage with existing S3-compatible SDKs or XML APIs.

:::note This page contains instructions on generating API tokens specifically for R2. Note that this is different from generating API tokens for other services, as documented in Create API token. :::

You must purchase R2 before you can generate an API token.

To create an API token:

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Under the Account Details section, select Manage next to API Tokens.

  3. Choose to create either:

    • Create Account API token - These tokens are tied to the Cloudflare account itself and can be used by any authorized system or user. Refer to Account API tokens for role and permission requirements. These tokens remain valid until manually revoked or until they reach a preset expiration date.
    • Create User API token - These tokens are tied to your individual Cloudflare user. They inherit your personal permissions and become inactive if your user is removed from the account.
  4. Under Permissions, choose a permission types for your token. Refer to Permissions for information about each option.

  5. (Optional) If you select the Object Read and Write or Object Read permissions, you can scope your token to a set of buckets.

  6. Select Create Account API token or Create User API token.

After your token has been successfully created, review your Secret Access Key and Access Key ID values. These may often be referred to as Client Secret and Client ID, respectively.

:::caution

You will not be able to access your Secret Access Key again after this step. Copy and record both values to avoid losing them.

:::

You will also need to configure the endpoint in your S3 client to https://<ACCOUNT_ID>.r2.cloudflarestorage.com.

Find your account ID in the Cloudflare dashboard.

Buckets created with jurisdictions must be accessed via jurisdiction-specific endpoints:

:::caution

Jurisdictional buckets can only be accessed via the corresponding jurisdictional endpoint. Most S3 clients will not let you configure multiple endpoints, so you'll generally have to initialize one client per jurisdiction.

:::

Permissions

Permission Description
Admin Read & Write Allows the ability to create, list, and delete buckets, edit bucket configuration, read, write, and list objects, and read and write to data catalog tables and associated metadata.
Admin Read only Allows the ability to list buckets and view bucket configuration, read and list objects, and read from the data catalog tables and associated metadata.
Object Read & Write Allows the ability to read, write, and list objects in specific buckets.
Object Read only Allows the ability to read and list objects in specific buckets.

:::note[Considerations]

Create API tokens via API

You can create API tokens via the API and use them to generate corresponding Access Key ID and Secret Access Key values. To get started, refer to Create API tokens via the API. Below are the specifics for R2.

Access Policy

An Access Policy specifies what resources the token can access and the permissions it has.

Resources

There are two relevant resource types for R2: Account and Bucket. For more information on the Account resource type, refer to Account.

Bucket

Include a set of R2 buckets or all buckets in an account.

A specific bucket is represented as:

"com.cloudflare.edge.r2.bucket.<ACCOUNT_ID>_<JURISDICTION>_<BUCKET_NAME>": "*"

All buckets in an account are represented as:

"com.cloudflare.api.account.<ACCOUNT_ID>": {
  "com.cloudflare.edge.r2.bucket.*": "*"
}

Permission groups

Determine what permission groups should be applied.

Workers R2 Storage Write Account Can create, delete, and list buckets, edit bucket configuration, and read, write, and list objects.
Workers R2 Storage Read Account Can list buckets and view bucket configuration, and read and list objects.
Workers R2 Storage Bucket Item Write Bucket Can read, write, and list objects in buckets.
Workers R2 Storage Bucket Item Read Bucket Can read and list objects in buckets.
Workers R2 Data Catalog Write Account Can read from and write to data catalogs. This permission allows access to the Iceberg REST catalog interface.
Workers R2 Data Catalog Read Account Can read from data catalogs. This permission allows read-only access to the Iceberg REST catalog interface.

Example Access Policy

[
	{
		"id": "f267e341f3dd4697bd3b9f71dd96247f",
		"effect": "allow",
		"resources": {
			"com.cloudflare.edge.r2.bucket.4793d734c0b8e484dfc37ec392b5fa8a_default_my-bucket": "*",
			"com.cloudflare.edge.r2.bucket.4793d734c0b8e484dfc37ec392b5fa8a_eu_my-eu-bucket": "*"
		},
		"permission_groups": [
			{
				"id": "6a018a9f2fc74eb6b293b0c548f38b39",
				"name": "Workers R2 Storage Bucket Item Read"
			}
		]
	}
]

Get S3 API credentials from an API token

You can get the Access Key ID and Secret Access Key values from the response of the Create Token API:

Refer to Authenticate against R2 API using auth tokens for a tutorial with JavaScript, Python, and Go examples.

Temporary credentials

To issue short-lived, scoped credentials derived from an API token, use temporary credentials. R2 supports generating them via the Temporary Credentials API or locally by signing a JWT with the parent token's secret access key.

S3 API compatibility

import { Details } from "~/components";

R2 implements the S3 API to allow users and their applications to migrate with ease. When comparing to AWS S3, Cloudflare has removed some API operations' features and added others. The S3 API operations are listed below with their current implementation status. Feature implementation is currently in progress. Refer back to this page for updates. The API is available via the https://<ACCOUNT_ID>.r2.cloudflarestorage.com endpoint. Find your account ID in the Cloudflare dashboard.

How to read this page

This page has two sections: bucket-level operations and object-level operations.

Each section will have two tables: a table of implemented APIs and a table of unimplemented APIs.

Refer the feature column of each table to review which features of an API have been implemented and which have not.

✅ Feature Implemented
🚧 Feature Implemented (Experimental)
❌ Feature Not Implemented

Bucket region

When using the S3 API, the region for an R2 bucket is auto. For compatibility with tools that do not allow you to specify a region, an empty value and us-east-1 will alias to the auto region.

This also applies to the LocationConstraint for the CreateBucket API.

Checksum Types

Checksums have an algorithm and a type. Refer to the table below.

Checksum Algorithm FULL_OBJECT COMPOSITE
CRC-64/NVME (CRC64NVME) ✅ ❌
CRC-32 (CRC32) ❌ ✅
CRC-32C (CRC32C) ❌ ✅
SHA-1 (SHA1) ❌ ✅
SHA-256 (SHA256) ❌ ✅

Bucket-level operations

The following tables are related to bucket-level operations.

Implemented bucket-level operations

Below is a list of implemented bucket-level operations. Refer to the Feature column to review which features have been implemented (✅) and have not been implemented (❌).

API Name Feature
✅ ListBuckets
✅ HeadBucket ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ CreateBucket ❌ ACL:
  ❌ x-amz-acl
  ❌ x-amz-grant-full-control
  ❌ x-amz-grant-read
  ❌ x-amz-grant-read-acp
  ❌ x-amz-grant-write
  ❌ x-amz-grant-write-acp
❌ Object Locking:
  ❌ x-amz-bucket-object-lock-enabled
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ DeleteBucket ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ DeleteBucketCors ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ GetBucketCors ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ GetBucketLifecycleConfiguration ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ GetBucketLocation ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ GetBucketEncryption ❌ Bucket Owner:
❌ x-amz-expected-bucket-owner
✅ PutBucketCors ❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ PutBucketLifecycleConfiguration ❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner

Unimplemented bucket-level operations

API Name Feature
❌ GetBucketAccelerateConfiguration ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketAcl ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketAnalyticsConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketIntelligentTieringConfiguration ❌ id
❌ GetBucketInventoryConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketLifecycle ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketLogging ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketMetricsConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketNotification ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketNotificationConfiguration ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketOwnershipControls ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketPolicy ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketPolicyStatus ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketReplication ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketRequestPayment ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketTagging ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketVersioning ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetBucketWebsite ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetObjectLockConfiguration ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ GetPublicAccessBlock ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ ListBucketAnalyticsConfigurations ❌ Query Parameters:
  ❌ continuation-token
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ ListBucketIntelligentTieringConfigurations ❌ Query Parameters:
  ❌ continuation-token
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ ListBucketInventoryConfigurations ❌ Query Parameters:
  ❌ continuation-token
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ ListBucketMetricsConfigurations ❌ Query Parameters:
  ❌ continuation-token
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketAccelerateConfiguration ❌ Checksums:
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketAcl ❌ Permissions:
  ❌ x-amz-grant-full-control
  ❌ x-amz-grant-read
  ❌ x-amz-grant-read-acp
  ❌ x-amz-grant-write
  ❌ x-amz-grant-write-acp
❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketAnalyticsConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketEncryption ❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketIntelligentTieringConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketInventoryConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketLifecycle ❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketLogging ❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketMetricsConfiguration ❌ id
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketNotification ❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:  
❌ x-amz-expected-bucket-owner
❌ PutBucketNotificationConfiguration ❌ Validation:
  ❌ x-amz-skip-destination-validation
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketOwnershipControls ❌ Checksums:
  ❌ Content-MD5
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketPolicy ❌ Validation:
  ❌ x-amz-confirm-remove-self-bucket-access
❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketReplication ❌ Object Locking:
  ❌ x-amz-bucket-object-lock-token
❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketRequestPayment ❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketTagging ❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketVersioning ❌ Multi-factor authentication:
  ❌ x-amz-mfa
❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutBucketWebsite ❌ Checksums:
  ❌ Content-MD5
❌ Bucket Owner:
❌ x-amz-expected-bucket-owner
❌ PutObjectLockConfiguration ❌ Object Locking:
  ❌ x-amz-bucket-object-lock-token
❌ Checksums:
  ❌ Content-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ PutPublicAccessBlock ❌ Checksums:
  ❌ Content-MD5
  ❌ x-amz-sdk-checksum-algorithm
  ❌ x-amz-checksum-algorithm
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner

Object-level operations

The following tables are related to object-level operations.

Implemented object-level operations

Below is a list of implemented object-level operations. Refer to the Feature column to review which features have been implemented (✅) and have not been implemented (❌).

Behaviors & Limitations

UploadPart: Uploading to the same part number replaces the previous part. If a subsequent upload to the same part fails, the original part is lost and must be re-uploaded.

Conditional operations: The conditional headers on PutObject (such as If-Match and If-None-Match) are evaluated atomically against the committed bucket state, as part of the same write that commits the object. For example, when multiple clients concurrently issue PutObject with If-None-Match: * for a previously absent key, at most one write succeeds and the others fail with 412 PreconditionFailed. Refer to Consistency model for details, including how negative reads interact with in-flight writes.

API Name Feature
✅ HeadObject ✅ Conditional Operations:
  ✅ If-Match
  ✅ If-Modified-Since
  ✅ If-None-Match
  ✅ If-Unmodified-Since
✅ Range:
  ✅ Range (has no effect in HeadObject)
  ✅ partNumber
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ ListObjects Query Parameters:
  ✅ delimiter
  ✅ encoding-type
  ✅ marker
  ✅ max-keys
  ✅ prefix
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ ListObjectsV2 Query Parameters:
  ✅ list-type
  ✅ continuation-token
  ✅ delimiter
  ✅ encoding-type
  ✅ fetch-owner
  ✅ max-keys
  ✅ prefix
  ✅ start-after
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ GetObject ✅ Conditional Operations:
  ✅ If-Match
  ✅ If-Modified-Since
  ✅ If-None-Match
  ✅ If-Unmodified-Since
✅ Range:
  ✅ Range
  ✅ PartNumber
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ PutObject ✅ Conditional Operations:
  ✅ If-Match
  ✅ If-Modified-Since
  ✅ If-None-Match
  ✅ If-Unmodified-Since
✅ System Metadata:
  ✅ Content-Type
  ✅ Cache-Control
  ✅ Content-Disposition
  ✅ Content-Encoding
  ✅ Content-Language
  ✅ Expires
  ✅ Content-MD5
✅ Storage Class:
  ✅ x-amz-storage-class
    ✅ STANDARD
    ✅ STANDARD_IA
❌ Object Lifecycle
❌ Website:
  ❌ x-amz-website-redirect-location
❌ SSE:
  ❌ x-amz-server-side-encryption-aws-kms-key-id
  ❌ x-amz-server-side-encryption
  ❌ x-amz-server-side-encryption-context
  ❌ x-amz-server-side-encryption-bucket-key-enabled
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Tagging:
  ❌ x-amz-tagging
❌ Object Locking:
  ❌ x-amz-object-lock-mode
  ❌ x-amz-object-lock-retain-until-date
  ❌ x-amz-object-lock-legal-hold
❌ ACL:
  ❌ x-amz-acl
  ❌ x-amz-grant-full-control
  ❌ x-amz-grant-read
  ❌ x-amz-grant-read-acp
  ❌ x-amz-grant-write-acp
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ DeleteObject ❌ Multi-factor authentication:
  ❌ x-amz-mfa
❌ Object Locking:
  ❌ x-amz-bypass-governance-retention
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ DeleteObjects ❌ Multi-factor authentication:
  ❌ x-amz-mfa
❌ Object Locking:
  ❌ x-amz-bypass-governance-retention
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ ListMultipartUploads ✅ Query Parameters:
  ✅ delimiter
  ✅ encoding-type
  ✅ key-marker
  ✅️ max-uploads
  ✅ prefix
  ✅ upload-id-marker
✅ CreateMultipartUpload ✅ System Metadata:
  ✅ Content-Type
  ✅ Cache-Control
  ✅ Content-Disposition
  ✅ Content-Encoding
  ✅ Content-Language
  ✅ Expires
  ✅ Content-MD5
✅ Storage Class:
  ✅ x-amz-storage-class
    ✅ STANDARD
    ✅ STANDARD_IA
❌ Website:
  ❌ x-amz-website-redirect-location
❌ SSE:
  ❌ x-amz-server-side-encryption-aws-kms-key-id
  ❌ x-amz-server-side-encryption
  ❌ x-amz-server-side-encryption-context
  ❌ x-amz-server-side-encryption-bucket-key-enabled
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Tagging:
  ❌ x-amz-tagging
❌ Object Locking:
  ❌ x-amz-object-lock-mode
  ❌ x-amz-object-lock-retain-until-date
  ❌ x-amz-object-lock-legal-hold
❌ ACL:
  ❌ x-amz-acl
  ❌ x-amz-grant-full-control
  ❌ x-amz-grant-read
  ❌ x-amz-grant-read-acp
  ❌ x-amz-grant-write-acp
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ CompleteMultipartUpload ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ Request Payer:
  ❌ x-amz-request-payer
✅ AbortMultipartUpload ❌ Request Payer:
  ❌ x-amz-request-payer
✅ CopyObject ✅ Operation Metadata:
  ✅ x-amz-metadata-directive
✅ System Metadata:
  ✅ Content-Type
  ✅ Cache-Control
  ✅ Content-Disposition
  ✅ Content-Encoding
  ✅ Content-Language
  ✅ Expires
✅ Conditional Operations:
  ✅ x-amz-copy-source
  ✅ x-amz-copy-source-if-match
  ✅ x-amz-copy-source-if-modified-since
  ✅ x-amz-copy-source-if-none-match
  ✅ x-amz-copy-source-if-unmodified-since
✅ Storage Class:
  ✅ x-amz-storage-class
    ✅ STANDARD
    ✅ STANDARD_IA
❌ ACL:
  ❌ x-amz-acl
  ❌ x-amz-grant-full-control
  ❌ x-amz-grant-read
  ❌ x-amz-grant-read-acp
  ❌ x-amz-grant-write-acp
❌ Website:
  ❌ x-amz-website-redirect-location
❌ SSE:
  ❌ x-amz-server-side-encryption
  ❌ x-amz-server-side-encryption-aws-kms-key-id
  ❌ x-amz-server-side-encryption-context
  ❌ x-amz-server-side-encryption-bucket-key-enabled
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
  ✅ x-amz-copy-source-server-side-encryption-customer-algorithm
  ✅ x-amz-copy-source-server-side-encryption-customer-key
  ✅ x-amz-copy-source-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Tagging:
  ❌ x-amz-tagging
  ❌ x-amz-tagging-directive
❌ Object Locking:
  ❌ x-amz-object-lock-mode
  ❌ x-amz-object-lock-retain-until-date
  ❌ x-amz-object-lock-legal-hold
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
  ❌ x-amz-source-expected-bucket-owner
❌ Checksums:
  ❌ x-amz-checksum-algorithm
✅ UploadPart ✅ System Metadata:
  ✅ Content-MD5
❌ SSE:
  ❌ x-amz-server-side-encryption
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
✅ UploadPartCopy ✅ Copy Source:
  ✅ x-amz-copy-source (required)
❌ Conditional Operations:
  ❌ x-amz-copy-source-if-match
  ❌ x-amz-copy-source-if-modified-since
  ❌ x-amz-copy-source-if-none-match
  ❌ x-amz-copy-source-if-unmodified-since
✅ Range:
  ✅ x-amz-copy-source-range
✅ SSE-C:
  ✅ x-amz-server-side-encryption-customer-algorithm
  ✅ x-amz-server-side-encryption-customer-key
  ✅ x-amz-server-side-encryption-customer-key-MD5
  ✅ x-amz-copy-source-server-side-encryption-customer-algorithm
  ✅ x-amz-copy-source-server-side-encryption-customer-key
  ✅ x-amz-copy-source-server-side-encryption-customer-key-MD5
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
  ❌ x-amz-source-expected-bucket-owner
✅ ListParts Query Parameters:
  ✅ max-parts
  ✅ part-number-marker
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner

:::caution

Even though ListObjects is a supported operation, it is recommended that you use ListObjectsV2 instead when developing applications. For more information, refer to ListObjects.

:::

Unimplemented object-level operations

API Name Feature
❌ GetObjectTagging ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ Request Payer:
  ❌ x-amz-request-payer
❌ PutObjectTagging ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner
❌ Request Payer:
  ❌ x-amz-request-payer
❌ Checksums:
  ❌ x-amz-sdk-checksum-algorithm
❌ DeleteObjectTagging ❌ Bucket Owner:
  ❌ x-amz-expected-bucket-owner

Extensions

R2 implements some extensions on top of the basic S3 API. This page outlines these additional, available features. Some of the functionality described in this page requires setting a custom header. For examples on how to do so, refer to Configure custom headers.

Extended metadata using Unicode

The Workers R2 API supports Unicode in keys and values natively without requiring any additional encoding or decoding for the customMetadata field. These fields map to the x-amz-meta--prefixed headers used within the R2 S3-compatible API endpoint.

HTTP header names and values may only contain ASCII characters, which is a small subset of the Unicode character library. To easily accommodate users, R2 adheres to RFC 2047 and automatically decodes all x-amz-meta-* header values before storage. On retrieval, any metadata values with unicode are RFC 2047-encoded before rendering the response. The length limit for metadata values is applied to the decoded Unicode value.

:::caution[Metadata variance]

Be mindful when using both Workers and S3 API endpoints to access the same data. If the R2 metadata keys contain Unicode, they are stripped when accessed through the S3 API and the x-amz-missing-meta header is set to the number of keys that were omitted. :::

These headers map to the httpMetadata field in the R2 bindings:

HTTP Header Property Name
Content-Encoding httpMetadata.contentEncoding
Content-Type httpMetadata.contentType
Content-Language httpMetadata.contentLanguage
Content-Disposition httpMetadata.contentDisposition
Cache-Control httpMetadata.cacheControl
Expires httpMetadata.expires

If using Unicode in object key names, refer to Unicode Interoperability.

Auto-creating buckets on upload

If you are creating buckets on demand, you might initiate an upload with the assumption that a target bucket exists. In this situation, if you received a NoSuchBucket error, you would probably issue a CreateBucket operation. However, following this approach can cause issues: if the body has already been partially consumed, the upload will need to be aborted. A common solution to this issue, followed by other object storage providers, is to use the HTTP 100 response to detect whether the body should be sent, or if the bucket must be created before retrying the upload. However, Cloudflare does not support the HTTP 100 response. Even if the HTTP 100 response was supported, you would still have additional latency due to the round trips involved.

To support sending an upload with a streaming body to a bucket that may not exist yet, upload operations such as PutObject or CreateMultipartUpload allow you to specify a header that will ensure the NoSuchBucket error is not returned. If the bucket does not exist at the time of upload, it is implicitly instantiated with the following CreateBucket request:

PUT / HTTP/1.1
Host: bucket.account.r2.cloudflarestorage.com
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
   <LocationConstraint>auto</LocationConstraint>
</CreateBucketConfiguration>

This is only useful if you are creating buckets on demand because you do not know the name of the bucket or the preferred access location ahead of time. For example, you have one bucket per one of your customers and the bucket is created on first upload to the bucket and not during account registration. In these cases, the ListBuckets extension, which supports accounts with more than 1,000 buckets, may also be useful.

PutObject and CreateMultipartUpload

cf-create-bucket-if-missing

Add a cf-create-bucket-if-missing header with the value true to implicitly create the bucket if it does not exist yet. Refer to Auto-creating buckets on upload for a more detailed explanation of when to add this header.

Conditional operations in PutObject

PutObject supports the standard S3 conditional headers If-Match, If-None-Match, If-Modified-Since, and If-Unmodified-Since. If-None-Match: * in particular is commonly used to implement "create-once" semantics: the write only succeeds if the key does not exist yet.

These conditions are evaluated atomically, as part of the same write that commits the object. If two or more clients concurrently issue PutObject with If-None-Match: * for a previously absent key, at most one request succeeds and every other request fails with a 412 PreconditionFailed error code. Competing conditional writes can never both observe success.

A negative read (NoSuchKey) is not proof that a prior write failed if that write's outcome is unknown — an in-flight write may still commit after the read returns. Refer to Consistency model for details.

CopyObject

MERGE metadata directive

The x-amz-metadata-directive allows a MERGE value, in addition to the standard COPY and REPLACE options. When used, MERGE is a combination of COPY and REPLACE, which will COPY any metadata keys from the source object and REPLACE those that are specified in the request with the new value. You cannot use MERGE to remove existing metadata keys from the source — use REPLACE instead.

ListBuckets

ListBuckets supports all the same search parameters as ListObjectsV2 in R2 because some customers may have more than 1,000 buckets. Because tooling, like existing S3 libraries, may not expose a way to set these search parameters, these values may also be sent in via headers. Values in headers take precedence over the search parameters.

Search parameter HTTP Header Meaning
prefix cf-prefix Show buckets with this prefix only.
start-after cf-start-after Show buckets whose name appears lexicographically in the account.
continuation-token cf-continuation-token Resume listing from a previously returned continuation token.
max-keys cf-max-keys Return this maximum number of buckets. Default and max is 1000.

The XML response contains a NextContinuationToken and IsTruncated elements as appropriate. Since these may not be accessible from existing S3 APIs, these are also available in response headers:

XML Response Element HTTP Response Header Meaning
IsTruncated cf-is-truncated This is set to true if the list of buckets returned is not all the buckets on the account.
NextContinuationToken cf-next-continuation-token This is set to continuation token to pass on a subsequent ListBuckets to resume the listing.
StartAfter This is the start-after value that was passed in on the request.
KeyCount The number of buckets returned.
ContinuationToken The continuation token that was supplied in the request.
MaxKeys The max keys that were specified in the request.

Conditional operations in CopyObject for the destination object

:::note

This feature is currently in beta. If you have feedback, reach out to us on the Cloudflare Developer Discord in the #r2-storage channel or open a thread on the Community Forum. :::

CopyObject already supports conditions that relate to the source object through the x-amz-copy-source-if-... headers as part of our compliance with the S3 API. In addition to this, R2 supports an R2 specific set of headers that allow the CopyObject operation to be conditional on the target object:

These headers work akin to the similarly named conditional headers supported on PutObject. When the preceding state of the destination object to does not match the specified conditions the CopyObject operation will be rejected with a 412 PreconditionFailed error code.

Non-atomicity relative to x-amz-copy-source-if

The x-amz-copy-source-if-... headers are guaranteed to be checked when the source object for the copy operation is selected, and the cf-copy-destination-if-... headers are guaranteed to be checked when the object is committed to the bucket state. However, the time at which the source object is selected for copying, and the point in time when the destination object is committed to the bucket state are not necessarily the same. This means that the cf-copy-destination-if-... headers are not atomic in relation to the x-amz-copy-source-if... headers.

S3

import { DirectoryListing } from "~/components";

Presigned URLs

import { Tabs, TabItem, LinkCard, TypeScriptExample } from "~/components";

Presigned URLs are an S3 concept for granting temporary access to objects without exposing your API credentials. A presigned URL includes signature parameters in the URL itself, authorizing anyone with the URL to perform a specific operation (like GetObject or PutObject) on a specific object until the URL expires.

They are ideal for granting temporary access to specific objects, such as allowing users to upload files directly to R2 or providing time-limited download links.

To generate a presigned URL, you specify:

  1. Resource identifier: Account ID, bucket name, and object path
  2. Operation: The S3 API operation permitted (GET, PUT, HEAD, or DELETE)
  3. Expiry: Timeout from 1 second to 7 days (604,800 seconds)

Presigned URLs are generated server-side with no communication with R2, requiring only your R2 API credentials and an implementation of the AWS Signature Version 4 signing algorithm.

Choosing an approach

R2 supports two patterns for time-limited access. They overlap but have different trade-offs:

Pattern Grants Good for
Presigned URLs (this page) A single S3 operation on a single object Granting direct HTTP access to a single object without an S3 client, such as a browser upload or a shareable download link
Temporary credentials Multiple S3 operations, scoped to a bucket and a set of permitted operations, and optionally to specific paths Callers that use a standard S3 client or SDK to perform multiple operations in a scoped session

Generate a presigned URL

Prerequisites

:::note[Generating presigned URLs from a Worker] The R2 Workers API binding cannot generate presigned URLs, so you must sign them with an S3 client. Inside a Worker, you can use aws4fetch, a small library built on fetch and SubtleCrypto, or the AWS SDK for JavaScript v3 with nodejs_compat enabled. :::

SDK examples

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const S3 = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	// Provide your Cloudflare account ID
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: '<ACCESS_KEY_ID>',
		secretAccessKey: '<SECRET_ACCESS_KEY>',
	},
});

// Generate presigned URL for reading (GET)
const getUrl = await getSignedUrl(
	S3,
	new GetObjectCommand({ Bucket: "my-bucket", Key: "image.png" }),
	{ expiresIn: 3600 }, // Valid for 1 hour
);
// https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/image.png?X-Amz-Algorithm=...

// Generate presigned URL for writing (PUT)
// Specify ContentType to restrict uploads to a specific file type
const putUrl = await getSignedUrl(
	S3,
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "image.png",
		ContentType: "image/png",
	}),
	{ expiresIn: 3600 },
);
import { AwsClient } from "aws4fetch";

export default {
	async fetch(request, env): Promise<Response> {
		const client = new AwsClient({
			service: "s3",
			region: "auto",
			// Store your S3 API credentials as secrets (see: https://developers.cloudflare.com/r2/api/tokens)
			accessKeyId: env.R2_ACCESS_KEY_ID,
			secretAccessKey: env.R2_SECRET_ACCESS_KEY,
		});

		const R2_URL = `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`;

		const getUrl = (
			await client.sign(
				new Request(`${R2_URL}/my-bucket/image.png?X-Amz-Expires=3600`),
				{ aws: { signQuery: true } },
			)
		).url.toString();

		const putUrl = (
			await client.sign(
				new Request(`${R2_URL}/my-bucket/image.png?X-Amz-Expires=3600`, {
					method: "PUT",
					headers: { "Content-Type": "image/png" },
				}),
				{ aws: { signQuery: true, allHeaders: true } },
			)
		).url.toString();

		return Response.json({ getUrl, putUrl });
	},
} satisfies ExportedHandler<Env>;
import boto3

s3 = boto3.client(
    service_name="s3",
    # Provide your Cloudflare account ID
    endpoint_url='https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
    # Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
    aws_access_key_id='<ACCESS_KEY_ID>',
    aws_secret_access_key='<SECRET_ACCESS_KEY>',
    region_name="auto", # Required by SDK but not used by R2
)

# Generate presigned URL for reading (GET)
get_url = s3.generate_presigned_url(
	'get_object',
	Params={'Bucket': 'my-bucket', 'Key': 'image.png'},
	ExpiresIn=3600  # Valid for 1 hour
)
# https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/image.png?X-Amz-Algorithm=...

# Generate presigned URL for writing (PUT)
# Specify ContentType to restrict uploads to a specific file type
put_url = s3.generate_presigned_url(
	'put_object',
	Params={
		'Bucket': 'my-bucket',
		'Key': 'image.png',
		'ContentType': 'image/png'
	},
	ExpiresIn=3600
)
# Generate presigned URL for reading (GET)
# The AWS CLI presign command defaults to GET operations
aws s3 presign --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
  s3://my-bucket/image.png \
  --expires-in 3600

# Output:
# https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...

# Note: The AWS CLI presign command only supports GET operations.
# For PUT operations, use one of the SDK examples above.

For complete examples and additional operations, refer to the SDK-specific documentation:

Best practices

When generating presigned URLs, you can limit abuse and misuse by:

Using a presigned URL

Once generated, use a presigned URL like any HTTP endpoint. The signature is embedded in the URL, so no additional authentication headers are required.

# Download using a GET presigned URL
curl "https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/image.png?X-Amz-Algorithm=..."

# Upload using a PUT presigned URL
curl -X PUT "https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/image.png?X-Amz-Algorithm=..." \
  --data-binary @image.png

You can also use presigned URLs directly in web browsers, mobile apps, or any HTTP client. The same presigned URL can be reused multiple times until it expires.

Presigned URL example

The following is an example of a presigned URL that was created using R2 API credentials and following the AWS Signature Version 4 signing process:

https://my-bucket.123456789abcdef0123456789abcdef.r2.cloudflarestorage.com/photos/cat.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=CFEXAMPLEKEY12345%2F20251201%2Fauto%2Fs3%2Faws4_request&X-Amz-Date=20251201T180512Z&X-Amz-Expires=3600&X-Amz-Signature=8c3ac40fa6c83d64b4516e0c9e5fa94c998bb79131be9ddadf90cefc5ec31033&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject

In this example, this presigned url performs a GetObject on the object photos/cat.png within bucket my-bucket in the account with id 123456789abcdef0123456789abcdef. The key signature parameters that compose this presigned URL are:

:::note The signature parameters (e.g. X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-Signature) cannot be tampered with. Attempting to modify the resource, operation, or expiry will result in a 403/SignatureDoesNotMatch error. :::

Supported operations

R2 supports presigned URLs for the following HTTP methods:

POST (multipart form uploads via HTML forms) is not currently supported.

Security considerations

Treat presigned URLs as bearer tokens. Anyone with the URL can perform the specified operation until it expires. Share presigned URLs only with intended recipients and consider using short expiration times for sensitive operations.

Custom domains

Presigned URLs work with the S3 API domain (<ACCOUNT_ID>.r2.cloudflarestorage.com) and cannot be used with custom domains.

If you need authentication with R2 buckets accessed via custom domains (public buckets), use the WAF HMAC validation feature (requires Pro plan or above).

Temporary credentials

import { Tabs, TabItem, LinkCard } from "~/components";

Temporary credentials are short-lived, scoped S3 credentials derived from an existing R2 API token. They authenticate with AWS Signature Version 4, the same as a long-lived token, but include a session token and expire automatically. The session token is sent with every request via the X-Amz-Security-Token header; all S3-compatible clients expose this as a standard session token credential field.

Use temporary credentials to delegate access without issuing a long-lived token. For example, granting a mobile client read access to a single prefix for 15 minutes, or issuing per-request upload credentials scoped to one object.

Choosing an approach

R2 supports two patterns for time-limited access. They overlap but have different trade-offs:

Pattern Grants Good for
Temporary credentials (this page) Multiple S3 operations, scoped to a bucket and a set of permitted operations, and optionally to specific paths Callers that use a standard S3 client or SDK to perform multiple operations in a scoped session
Presigned URLs A single S3 operation on a single object Granting direct HTTP access to a single object without an S3 client, such as a browser upload or a shareable download link

Generate temporary credentials

Via the Temporary Credentials API

The Temporary Credentials API accepts a parent API token, the bucket name, and optional scoping parameters, and returns a new access key ID, secret access key, and session token. Cloudflare signs the session token on your behalf.

Use this method when you want Cloudflare to manage the signing flow for you.

For a runnable walkthrough, refer to Authenticate against R2 with temporary credentials.

Locally (client-side signing)

You can also generate temporary credentials locally by signing a JWT with your parent API token's secret access key and using it as the session token.

Use this method when:

Signing happens in three steps:

  1. Build a JWT payload that identifies the bucket and the scope of access.
  2. Sign the JWT with HS256 using your parent secret access key.
  3. Derive the temporary secret access key by taking the SHA-256 hex digest of the signed JWT. Encode the session token as base64("jwt/" + <signed-jwt>).

The parent access key ID is reused as the temporary access key ID.

A complete, runnable example is available in Authenticate against R2 with temporary credentials.

Scope of a credential

Every temporary credential is bound to a single bucket and a set of permitted operations. You can optionally restrict the credential further to specific paths within the bucket.

A temporary credential cannot exceed the permissions of its parent token.

Bucket

A temporary credential is bound to exactly one bucket, identified by name. Cross-bucket access is not supported within a single credential.

Permitted operations

Specify permitted operations using either scope (passed as permission to the API) or actions. You must provide exactly one. A credential that sets both, or neither, is rejected with a 400 InvalidArgument error on X-Amz-Security-Token.

Scope

scope is a preset category of operations. Refer to Permissions for full definitions.

Scope Allows
object-read-only Read and list objects in the bucket.
object-read-write Read, write, and list objects in the bucket.
admin-read-only Read and list objects, view bucket configuration, and read from the data catalog.
admin-read-write Read, write, and list objects, edit bucket configuration, and read and write to the data catalog.

Actions

actions is a non-empty list of permitted S3 operations.

For example, actions: ["GetObject", "HeadObject"] grants read of individual objects but denies ListObjectsV2, even though the broader object-read-only scope would allow listing. Do not set scope alongside actions.

:::note actions is currently supported via local signing only. Support in the Temporary Credentials API is coming soon. :::

Valid actions:

Category Actions
Read HeadObject, GetObject, GetBucketLocation, ListObjectsV1, ListObjectsV2, ListMultipartUploads, ListParts
Write PutObject, DeleteObject, DeleteObjects, CopyObject
Multipart CreateMultipartUpload, UploadPart, UploadPartCopy, AbortMultipartUpload, CompleteMultipartUpload

Paths

Restrict access to specific prefixes or objects within the bucket. Omit these fields to grant access to the entire bucket, subject to the permitted operations.

Temporary Credentials API: pass prefixes and objects as top-level fields on the request body.

{
  "prefixes": ["uploads/user-123/"],
  "objects": ["shared/manifest.json"]
}

Local signing: set paths.prefixPaths and paths.objectPaths on the JWT payload.

{
  "paths": {
    "prefixPaths": ["uploads/user-123/"],
    "objectPaths": ["shared/manifest.json"]
  }
}

Using temporary credentials

Any S3-compatible client that supports session tokens will accept R2 temporary credentials. Pass all three values (access key ID, secret access key, session token) using the client's standard credential fields.

import { AwsClient } from "aws4fetch";

const R2_URL = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;

const client = new AwsClient({
	accessKeyId: ACCESS_KEY_ID,
	secretAccessKey: SECRET_ACCESS_KEY,
	sessionToken: SESSION_TOKEN,
	service: "s3",
});

const response = await client.fetch(`${R2_URL}/my-bucket/image.png`);
import boto3

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    aws_session_token="<SESSION_TOKEN>",
    region_name="auto",
)

Most AWS SDKs and the AWS CLI read credentials from these environment variables by default:

AWS_ACCESS_KEY_ID=<ACCESS_KEY_ID>
AWS_SECRET_ACCESS_KEY=<SECRET_ACCESS_KEY>
AWS_SESSION_TOKEN=<SESSION_TOKEN>

Security considerations

Treat temporary credentials as bearer tokens. Anyone in possession of all three values can perform the allowed operations until the credential expires.

Workers API

import { DirectoryListing } from "~/components";

Workers API reference

import { Type, MetaInfo, WranglerConfig, TabItem, Tabs } from "~/components";

The in-Worker R2 API is accessed by binding an R2 bucket to a Worker. The Worker you write can expose external access to buckets via a route or manipulate R2 objects internally.

The R2 API includes some extensions and semantic differences from the S3 API. If you need S3 compatibility, consider using the S3-compatible API.

Concepts

R2 organizes the data you store, called objects, into containers, called buckets. Buckets are the fundamental unit of performance, scaling, and access within R2.

Create a binding

:::note[Bindings]

A binding is how your Worker interacts with external resources such as KV Namespaces, Durable Objects, or R2 Buckets. A binding is a runtime variable that the Workers runtime provides to your code. You can declare a variable name in your Wrangler file that will be bound to these resources at runtime, and interact with them through this variable. Every binding's variable name and behavior is determined by you when deploying the Worker. Refer to Environment Variables for more information.

A binding is defined in the Wrangler file of your Worker project's directory.

:::

To bind your R2 bucket to your Worker, add the following to your Wrangler file. Update the binding property to a valid JavaScript variable identifier and bucket_name to the name of your R2 bucket:

{
	"r2_buckets": [
		{
			"binding": "MY_BUCKET", // <~ valid JavaScript variable name
			"bucket_name": "<YOUR_BUCKET_NAME>"
		}
	]
}

Within your Worker, your bucket binding is now available under the MY_BUCKET variable and you can begin interacting with it using the bucket methods described below.

Bucket method definitions

The following methods are available on the bucket binding object injected into your code.

For example, to issue a PUT object request using the binding above:

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const key = url.pathname.slice(1);

		switch (request.method) {
			case "PUT":
				await env.MY_BUCKET.put(key, request.body);
				return new Response(`Put ${key} successfully!`);

			default:
				return new Response(`${request.method} is not allowed.`, {
					status: 405,
					headers: {
						Allow: "PUT",
					},
				});
		}
	},
};
from workers import WorkerEntrypoint, Response
from urllib.parse import urlparse

class Default(WorkerEntrypoint):
	async def fetch(self, request):
		url = urlparse(request.url)
		key = url.path[1:]

		if request.method == "PUT":
			await self.env.MY_BUCKET.put(key, request.body)
			return Response(f"Put {key} successfully!")
		else:
			return Response(
				f"{request.method} is not allowed.",
				status=405,
				headers={"Allow": "PUT"}
			)

R2Object definition

R2Object is created when you PUT an object into an R2 bucket. R2Object represents the metadata of an object based on the information provided by the uploader. Every object that you PUT into an R2 bucket will have an R2Object created.

:::note

Cloudflare recommends using the httpEtag field when returning an etag in a response header. This ensures the etag is quoted and conforms to RFC 9110. :::

R2ObjectBody definition

R2ObjectBody represents an object's metadata combined with its body. It is returned when you GET an object from an R2 bucket. The full list of keys for R2ObjectBody includes the list below and all keys inherited from R2Object.

R2MultipartUpload definition

An R2MultipartUpload object is created when you call createMultipartUpload or resumeMultipartUpload. R2MultipartUpload is a representation of an ongoing multipart upload.

Uncompleted multipart uploads will be automatically aborted after 7 days.

:::note

An R2MultipartUpload object does not guarantee that there is an active underlying multipart upload corresponding to that object.

A multipart upload can be completed or aborted at any time, either through the S3 API, or by a parallel invocation of your Worker. Therefore it is important to add the necessary error handling code around each operation on a R2MultipartUpload object in case the underlying multipart upload no longer exists.

:::

Method-specific types

R2GetOptions

Ranged reads

R2GetOptions accepts a range parameter, which can be used to restrict the data returned in body.

There are 3 variations of arguments that can be used in a range:

R2PutOptions

:::note

Only a single hashing algorithm can be specified at once.

:::

R2MultipartOptions

R2ListOptions

const options = {
	limit: 500,
	include: ["customMetadata"],
};

const listed = await env.MY_BUCKET.list(options);

let truncated = listed.truncated;
let cursor = truncated ? listed.cursor : undefined;

// ❌ - if your limit can't fit into a single response or your
// bucket has less objects than the limit, it will get stuck here.
while (listed.objects.length < options.limit) {
	// ...
}

// ✅ - use the truncated property to check if there are more
// objects to be returned
while (truncated) {
	const next = await env.MY_BUCKET.list({
		...options,
		cursor: cursor,
	});
	listed.objects.push(...next.objects);

	truncated = next.truncated;
	cursor = next.cursor;
}

limit = 500
include = ["customMetadata"]

listed = await self.env.MY_BUCKET.list(limit=limit, include=include)

truncated = listed.truncated
cursor = listed.cursor if truncated else None

# ❌ - if your limit can't fit into a single response or your
# bucket has less objects than the limit, it will get stuck here.
while len(listed.objects) < limit:
    ...

# ✅ - use the truncated property to check if there are more
# objects to be returned
while truncated:
    next_page = await self.env.MY_BUCKET.list(limit=limit, include=include, cursor=cursor)
    listed.objects.extend(next_page.objects)

    truncated = next_page.truncated
    cursor = next_page.cursor

R2Objects

An object containing an R2Object array, returned by BUCKET_BINDING.list().

Conditional operations

You can pass an R2Conditional object to R2GetOptions and R2PutOptions. If the condition check for get() fails, the body will not be returned. This will make get() have lower latency.

If the condition check for put() fails, null will be returned instead of the R2Object.

Alternatively, you can pass a Headers object containing conditional headers to R2GetOptions and R2PutOptions. For information on these conditional headers, refer to the MDN docs on conditional requests. All conditional headers aside from If-Range are supported.

For more specific information about conditional requests, refer to RFC 7232.

HTTP Metadata

Generally, these fields match the HTTP metadata passed when the object was created. They can be overridden when issuing GET requests, in which case, the given values will be echoed back in the response.

Checksums

If a checksum was provided when using the put() binding, it will be available on the returned object under the checksums property. The MD5 checksum will be included by default for non-multipart objects.

R2UploadedPart

An R2UploadedPart object represents a part that has been uploaded. R2UploadedPart objects are returned from uploadPart operations and must be passed to completeMultipartUpload operations.

Storage Class

The storage class where an R2Object is stored. The available storage classes are Standard and InfrequentAccess. Refer to Storage classes for more information.

Use R2 from Workers

import { Render, PackageManagers, WranglerConfig } from "~/components";

1. Create a new application with C3

C3 (create-cloudflare-cli) is a command-line tool designed to help you set up and deploy Workers & Pages applications to Cloudflare as fast as possible.

To get started, open a terminal window and run:

<PackageManagers type="create" pkg="cloudflare@latest" args={"r2-worker"} />

<Render file="c3-post-run-steps-hello-world" product="workers" params={{ type: "Worker only", lang: "JavaScript", }} />

Then, move into your newly created directory:

cd r2-worker

2. Create your bucket

Create your bucket by running:

npx wrangler r2 bucket create <YOUR_BUCKET_NAME>

To check that your bucket was created, run:

npx wrangler r2 bucket list

After running the list command, you will see all bucket names, including the one you have just created.

3. Bind your bucket to a Worker

You will need to bind your bucket to a Worker.

:::note[Bindings]

A binding is how your Worker interacts with external resources such as KV Namespaces, Durable Objects, or R2 Buckets. A binding is a runtime variable that the Workers runtime provides to your code. You can declare a variable name in your Wrangler file that will be bound to these resources at runtime, and interact with them through this variable. Every binding's variable name and behavior is determined by you when deploying the Worker. Refer to the Environment Variables documentation for more information.

A binding is defined in the Wrangler file of your Worker project's directory.

:::

To bind your R2 bucket to your Worker, add the following to your Wrangler file. Update the binding property to a valid JavaScript variable identifier and bucket_name to the <YOUR_BUCKET_NAME> you used to create your bucket in step 2:

{
	"r2_buckets": [
		{
			"binding": "MY_BUCKET", // <~ valid JavaScript variable name
			"bucket_name": "<YOUR_BUCKET_NAME>"
		}
	]
}

For more detailed information on configuring your Worker (for example, if you are using jurisdictions), refer to the Wrangler Configuration documentation.

4. Access your R2 bucket from your Worker

Within your Worker code, your bucket is now available under the MY_BUCKET variable and you can begin interacting with it.

:::caution[Local Development mode in Wrangler]

By default wrangler dev runs in local development mode. In this mode, all operations performed by your local worker will operate against local storage on your machine.

If you want the R2 operations that are performed during development to be performed against a real R2 bucket, you can set "remote" : true in the R2 binding configuration. Refer to remote bindings documentation for more information.

:::

An R2 bucket is able to READ, LIST, WRITE, and DELETE objects. You can see an example of all operations below using the Module Worker syntax. Add the following snippet into your project's index.js file:

import { TabItem, Tabs } from "~/components";

import { WorkerEntrypoint } from "cloudflare:workers";

export default class extends WorkerEntrypoint<Env> {
  async fetch(request: Request) {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);

    switch (request.method) {
      case "PUT": {
        await this.env.MY_BUCKET.put(key, request.body, {
          onlyIf: request.headers,
          httpMetadata: request.headers,
        });
        return new Response(`Put ${key} successfully!`);
      }
      case "GET": {
        const object = await this.env.MY_BUCKET.get(key, {
          onlyIf: request.headers,
          range: request.headers,
        });

        if (object === null) {
          return new Response("Object Not Found", { status: 404 });
        }

        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set("etag", object.httpEtag);

        // When no body is present, preconditions have failed
        return new Response("body" in object ? object.body : undefined, {
          status: "body" in object ? 200 : 412,
          headers,
        });
      }
      case "DELETE": {
        await this.env.MY_BUCKET.delete(key);
        return new Response("Deleted!");
      }
      default:
        return new Response("Method Not Allowed", {
          status: 405,
          headers: {
            Allow: "PUT, GET, DELETE",
          },
        });
    }
  }
};

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);

    switch (request.method) {
      case "PUT": {
        await env.MY_BUCKET.put(key, request.body, {
          onlyIf: request.headers,
          httpMetadata: request.headers,
        });
        return new Response(`Put ${key} successfully!`);
      }
      case "GET": {
        const object = await env.MY_BUCKET.get(key, {
          onlyIf: request.headers,
          range: request.headers,
        });

        if (object === null) {
          return new Response("Object Not Found", { status: 404 });
        }

        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set("etag", object.httpEtag);

        // When no body is present, preconditions have failed
        return new Response("body" in object ? object.body : undefined, {
          status: "body" in object ? 200 : 412,
          headers,
        });
      }
      case "DELETE": {
        await env.MY_BUCKET.delete(key);
        return new Response("Deleted!");
      }
      default:
        return new Response("Method Not Allowed", {
          status: 405,
          headers: {
            Allow: "PUT, GET, DELETE",
          },
        });
    }
  }
}
```py from workers import WorkerEntrypoint, Response from urllib.parse import urlparse

class Default(WorkerEntrypoint): async def fetch(self, request): url = urlparse(request.url) key = url.path[1:]

	if request.method == "PUT":
		await self.env.MY_BUCKET.put(
			key,
			request.body,
			onlyIf=request.headers,
			httpMetadata=request.headers,
		)
		return Response(f"Put {key} successfully!")
	elif request.method == "GET":
		obj = await self.env.MY_BUCKET.get(
			key,
			onlyIf=request.headers,
			range=request.headers,
		)

		if obj is None:
			return Response("Object Not Found", status=404)

		# When no body is present, preconditions have failed
		body = obj.body if hasattr(obj, "body") else None
		status = 200 if hasattr(obj, "body") else 412

		headers = {"etag": obj.httpEtag}
		return Response(body, status=status, headers=headers)
	elif request.method == "DELETE":
		await self.env.MY_BUCKET.delete(key)
		return Response("Deleted!")
	else:
		return Response(
			"Method Not Allowed",
			status=405,
			headers={"Allow": "PUT, GET, DELETE"},
		)
</TabItem>
</Tabs>

<Render file="request-dot-clone-warning" product="workers" />

## 5. Bucket access and privacy

With the above code added to your Worker, every incoming request has the ability to interact with your bucket. This means your bucket is publicly exposed and its contents can be accessed and modified by undesired actors.

You must now define authorization logic to determine who can perform what actions to your bucket. This logic lives within your Worker's code, as it is your application's job to determine user privileges. The following is a short list of resources related to access and authorization practices:

1. [Basic Authentication](/workers/examples/basic-auth/): Shows how to restrict access using the HTTP Basic schema.
2. [Using Custom Headers](/workers/examples/auth-with-headers/): Allow or deny a request based on a known pre-shared key in a header.

{/* <!-- 3. [Authorizing users with Auth0](/workers/tutorials/authorize-users-with-auth0/#overview): Integrate Auth0, an identity management platform, into a Cloudflare Workers application. --> */}

Continuing with your newly created bucket and Worker, you will need to protect all bucket operations.

For `PUT` and `DELETE` requests, you will make use of a new `AUTH_KEY_SECRET` environment variable, which you will define later as a Wrangler secret.

For `GET` requests, you will ensure that only a specific file can be requested. All of this custom logic occurs inside of an `authorizeRequest` function, with the `hasValidHeader` function handling the custom header logic. If all validation passes, then the operation is allowed.

<Tabs syncKey="workersExamples">
<TabItem label="JavaScript" icon="seti:javascript">

```js
const ALLOW_LIST = ["cat-pic.jpg"];

// Check requests for a pre-shared secret
const hasValidHeader = (request, env) => {
	return request.headers.get("X-Custom-Auth-Key") === env.AUTH_KEY_SECRET;
};

function authorizeRequest(request, env, key) {
	switch (request.method) {
		case "PUT":
		case "DELETE":
			return hasValidHeader(request, env);
		case "GET":
			return ALLOW_LIST.includes(key);
		default:
			return false;
	}
}

export default {
	async fetch(request, env, ctx) {
		const url = new URL(request.url);
		const key = url.pathname.slice(1);

		if (!authorizeRequest(request, env, key)) {
			return new Response("Forbidden", { status: 403 });
		}

		// ...
	},
};
from workers import WorkerEntrypoint, Response
from urllib.parse import urlparse

ALLOW_LIST = ["cat-pic.jpg"]

# Check requests for a pre-shared secret
def has_valid_header(request, env):
	return request.headers.get("X-Custom-Auth-Key") == env.AUTH_KEY_SECRET

def authorize_request(request, env, key):
	if request.method in ["PUT", "DELETE"]:
		return has_valid_header(request, env)
	elif request.method == "GET":
		return key in ALLOW_LIST
	else:
		return False

class Default(WorkerEntrypoint):
	async def fetch(self, request):
		url = urlparse(request.url)
		key = url.path[1:]

		if not authorize_request(request, self.env, key):
			return Response("Forbidden", status=403)

		# ...

For this to work, you need to create a secret via Wrangler:

npx wrangler secret put AUTH_KEY_SECRET

This command will prompt you to enter a secret in your terminal:

npx wrangler secret put AUTH_KEY_SECRET
Enter the secret text you'd like assigned to the variable AUTH_KEY_SECRET on the script named <YOUR_WORKER_NAME>:
*********
🌀  Creating the secret for script name <YOUR_WORKER_NAME>
✨  Success! Uploaded secret AUTH_KEY_SECRET.

This secret is now available as AUTH_KEY_SECRET on the env parameter in your Worker.

6. Deploy your Worker

With your Worker and bucket set up, run the npx wrangler deploy command to deploy to Cloudflare's global network:

npx wrangler deploy

You can verify your authorization logic is working through the following commands, using your deployed Worker endpoint:

:::caution

When uploading files to R2 via curl, ensure you use --data-binary instead of --data or -d. Files will otherwise be truncated. :::

# Attempt to write an object without providing the "X-Custom-Auth-Key" header
curl https://your-worker.dev/cat-pic.jpg -X PUT --data-binary 'test'
#=> Forbidden
# Expected because header was missing

# Attempt to write an object with the wrong "X-Custom-Auth-Key" header value
curl https://your-worker.dev/cat-pic.jpg -X PUT --header "X-Custom-Auth-Key: hotdog" --data-binary 'test'
#=> Forbidden
# Expected because header value did not match the AUTH_KEY_SECRET value

# Attempt to write an object with the correct "X-Custom-Auth-Key" header value
# Note: Assume that "*********" is the value of your AUTH_KEY_SECRET Wrangler secret
curl https://your-worker.dev/cat-pic.jpg -X PUT --header "X-Custom-Auth-Key: *********" --data-binary 'test'
#=> Put cat-pic.jpg successfully!

# Attempt to read object called "foo"
curl https://your-worker.dev/foo
#=> Forbidden
# Expected because "foo" is not in the ALLOW_LIST

# Attempt to read an object called "cat-pic.jpg"
curl https://your-worker.dev/cat-pic.jpg
#=> test
# Note: This is the value that was successfully PUT above

By completing this guide, you have successfully installed Wrangler and deployed your R2 bucket to Cloudflare.

  1. Workers Tutorials
  2. Workers Examples

Use the R2 multipart API from Workers

By following this guide, you will create a Worker through which your applications can perform multipart uploads. This example worker could serve as a basis for your own use case where you can add authentication to the worker, or even add extra validation logic when uploading each part. This guide also contains an example Python application that uploads files to this worker.

This guide assumes you have set up the R2 binding for your Worker. Refer to Use R2 from Workers for instructions on setting up an R2 binding.

An example Worker using the multipart API

The following example Worker exposes an HTTP API which enables applications to use the multipart API through the Worker.

In this example, each request is routed based on the HTTP method and the action request parameter. As your Worker becomes more complicated, consider utilizing a serverless web framework such as Hono to handle the routing for you.

The following example Worker includes any new information about the state of the multipart upload in the response to each request. For the request which creates the multipart upload, the uploadId is returned. For requests uploading a part, the part number and etag are returned. In turn, the client keeps track of this state, and includes the uploadId in subsequent requests, and the etag and part number of each part when completing a multipart upload.

import { Tabs, TabItem } from "~/components";

Add the following code to your project's index.ts file and replace MY_BUCKET with your bucket's name:

interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(
    request,
    env,
    ctx
  ): Promise<Response> {
    const bucket = env.MY_BUCKET;

    const url = new URL(request.url);
    const key = url.pathname.slice(1);
    const action = url.searchParams.get("action");

    if (action === null) {
      return new Response("Missing action type", { status: 400 });
    }

    // Route the request based on the HTTP method and action type
    switch (request.method) {
      case "POST":
        switch (action) {
          case "mpu-create": {
            const multipartUpload = await bucket.createMultipartUpload(key);
            return new Response(
              JSON.stringify({
                key: multipartUpload.key,
                uploadId: multipartUpload.uploadId,
              })
            );
          }
          case "mpu-complete": {
            const uploadId = url.searchParams.get("uploadId");
            if (uploadId === null) {
              return new Response("Missing uploadId", { status: 400 });
            }

            const multipartUpload = env.MY_BUCKET.resumeMultipartUpload(
              key,
              uploadId
            );

            interface completeBody {
              parts: R2UploadedPart[];
            }
            const completeBody: completeBody = await request.json();
            if (completeBody === null) {
              return new Response("Missing or incomplete body", {
                status: 400,
              });
            }

            // Error handling in case the multipart upload does not exist anymore
            try {
              const object = await multipartUpload.complete(completeBody.parts);
              return new Response(null, {
                headers: {
                  etag: object.httpEtag,
                },
              });
            } catch (error: any) {
              return new Response(error.message, { status: 400 });
            }
          }
          default:
            return new Response(`Unknown action ${action} for POST`, {
              status: 400,
            });
        }
      case "PUT":
        switch (action) {
          case "mpu-uploadpart": {
            const uploadId = url.searchParams.get("uploadId");
            const partNumberString = url.searchParams.get("partNumber");
            if (partNumberString === null || uploadId === null) {
              return new Response("Missing partNumber or uploadId", {
                status: 400,
              });
            }
            if (request.body === null) {
              return new Response("Missing request body", { status: 400 });
            }

            const partNumber = parseInt(partNumberString);
            const multipartUpload = env.MY_BUCKET.resumeMultipartUpload(
              key,
              uploadId
            );
            try {
              const uploadedPart: R2UploadedPart =
                await multipartUpload.uploadPart(partNumber, request.body);
              return new Response(JSON.stringify(uploadedPart));
            } catch (error: any) {
              return new Response(error.message, { status: 400 });
            }
          }
          default:
            return new Response(`Unknown action ${action} for PUT`, {
              status: 400,
            });
        }
      case "GET":
        if (action !== "get") {
          return new Response(`Unknown action ${action} for GET`, {
            status: 400,
          });
        }
        const object = await env.MY_BUCKET.get(key);
        if (object === null) {
          return new Response("Object Not Found", { status: 404 });
        }
        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set("etag", object.httpEtag);
        return new Response(object.body, { headers });
      case "DELETE":
        switch (action) {
          case "mpu-abort": {
            const uploadId = url.searchParams.get("uploadId");
            if (uploadId === null) {
              return new Response("Missing uploadId", { status: 400 });
            }
            const multipartUpload = env.MY_BUCKET.resumeMultipartUpload(
              key,
              uploadId
            );

            try {
              multipartUpload.abort();
            } catch (error: any) {
              return new Response(error.message, { status: 400 });
            }
            return new Response(null, { status: 204 });
          }
          case "delete": {
            await env.MY_BUCKET.delete(key);
            return new Response(null, { status: 204 });
          }
          default:
            return new Response(`Unknown action ${action} for DELETE`, {
              status: 400,
            });
        }
      default:
        return new Response("Method Not Allowed", {
          status: 405,
          headers: { Allow: "PUT, POST, GET, DELETE" },
        });
    }
  },
} satisfies ExportedHandler<Env>;

from workers import WorkerEntrypoint, Response
from urllib.parse import urlparse, parse_qs
import json


class Default(WorkerEntrypoint):
    async def fetch(self, request):
        bucket = self.env.MY_BUCKET

        url = urlparse(request.url)
        key = url.path[1:]
        params = parse_qs(url.query)
        action = params.get("action", [None])[0]

        if action is None:
            return Response("Missing action type", status=400)

        if request.method == "POST":
            if action == "mpu-create":
                multipart_upload = await bucket.createMultipartUpload(key)
                return Response.json({
                    "key": multipart_upload.key,
                    "uploadId": multipart_upload.uploadId,
                })
            elif action == "mpu-complete":
                upload_id = params.get("uploadId", [None])[0]
                if upload_id is None:
                    return Response("Missing uploadId", status=400)

                multipart_upload = bucket.resumeMultipartUpload(key, upload_id)
                complete_body = await request.json()
                if complete_body is None:
                    return Response("Missing or incomplete body", status=400)

                try:
                    obj = await multipart_upload.complete(complete_body.parts)
                    return Response(None, headers={"etag": obj.httpEtag})
                except Exception as error:
                    return Response(str(error), status=400)
            else:
                return Response(f"Unknown action {action} for POST", status=400)

        elif request.method == "PUT":
            if action == "mpu-uploadpart":
                upload_id = params.get("uploadId", [None])[0]
                part_number_str = params.get("partNumber", [None])[0]
                if part_number_str is None or upload_id is None:
                    return Response("Missing partNumber or uploadId", status=400)
                if request.body is None:
                    return Response("Missing request body", status=400)

                part_number = int(part_number_str)
                multipart_upload = bucket.resumeMultipartUpload(key, upload_id)
                try:
                    uploaded_part = await multipart_upload.uploadPart(part_number, request.body)
                    return Response.json(uploaded_part)
                except Exception as error:
                    return Response(str(error), status=400)
            else:
                return Response(f"Unknown action {action} for PUT", status=400)

        elif request.method == "GET":
            if action != "get":
                return Response(f"Unknown action {action} for GET", status=400)
            obj = await bucket.get(key)
            if obj is None:
                return Response("Object Not Found", status=404)
            body = await obj.text()
            headers = {"etag": obj.httpEtag}
            return Response(body, headers=headers)

        elif request.method == "DELETE":
            if action == "mpu-abort":
                upload_id = params.get("uploadId", [None])[0]
                if upload_id is None:
                    return Response("Missing uploadId", status=400)
                multipart_upload = bucket.resumeMultipartUpload(key, upload_id)
                try:
                    await multipart_upload.abort()
                except Exception as error:
                    return Response(str(error), status=400)
                return Response(None, status=204)
            elif action == "delete":
                await bucket.delete(key)
                return Response(None, status=204)
            else:
                return Response(f"Unknown action {action} for DELETE", status=400)

        else:
            return Response(
                "Method Not Allowed",
                status=405,
                headers={"Allow": "PUT, POST, GET, DELETE"},
            )

After you have updated your Worker with the above code, run npx wrangler deploy.

You can now use this Worker to perform multipart uploads. You can either send requests from your existing application to this Worker to perform uploads or use a script to upload files through this Worker.

The next section is optional and shows an example of a Python script which uploads a chosen file on your machine to your Worker.

Perform a multipart upload with your Worker (optional)

This example application uploads a local file to the Worker in multiple parts. It uses Python's built-in ThreadPoolExecutor to parallelize the uploading of parts to the Worker, which increases upload speeds. HTTP requests to the Worker are made with the requests library.

Utilizing the multipart API in this way also allows you to use your Worker to upload files larger than the Workers request body size limit. The uploading of individual parts is still subject to this limit.

Save the following code in a file named mpuscript.py on your local machine. Change the worker_endpoint variable to where your worker is deployed. Pass the file you want to upload as an argument when running this script: python3 mpuscript.py myfile. This will upload the file myfile from your machine to your bucket through the Worker.

import math
import os
import requests
from requests.adapters import HTTPAdapter, Retry
import sys
import concurrent.futures

# Take the file to upload as an argument
filename = sys.argv[1]
# The endpoint for our worker, change this to wherever you deploy your worker
worker_endpoint = "https://myworker.myzone.workers.dev/"
# Configure the part size to be 10MB. 5MB is the minimum part size, except for the last part
partsize = 10 * 1024 * 1024


def upload_file(worker_endpoint, filename, partsize):
    url = f"{worker_endpoint}{filename}"

    # Create the multipart upload
    uploadId = requests.post(url, params={"action": "mpu-create"}).json()["uploadId"]

    part_count = math.ceil(os.stat(filename).st_size / partsize)
    # Create an executor for up to 25 concurrent uploads.
    executor = concurrent.futures.ThreadPoolExecutor(25)
    # Submit a task to the executor to upload each part
    futures = [
        executor.submit(upload_part, filename, partsize, url, uploadId, index)
        for index in range(part_count)
    ]
    concurrent.futures.wait(futures)
    # get the parts from the futures
    uploaded_parts = [future.result() for future in futures]

    # complete the multipart upload
    response = requests.post(
        url,
        params={"action": "mpu-complete", "uploadId": uploadId},
        json={"parts": uploaded_parts},
    )
    if response.status_code == 200:
        print("🎉 successfully completed multipart upload")
    else:
        print(response.text)


def upload_part(filename, partsize, url, uploadId, index):
    # Open the file in rb mode, which treats it as raw bytes rather than attempting to parse utf-8
    with open(filename, "rb") as file:
        file.seek(partsize * index)
        part = file.read(partsize)

    # Retry policy for when uploading a part fails
    s = requests.Session()
    retries = Retry(total=3, status_forcelist=[400, 500, 502, 503, 504])
    s.mount("https://", HTTPAdapter(max_retries=retries))

    return s.put(
        url,
        params={
            "action": "mpu-uploadpart",
            "uploadId": uploadId,
            "partNumber": str(index + 1),
        },
        data=part,
    ).json()


upload_file(worker_endpoint, filename, partsize)

State management

The stateful nature of multipart uploads does not easily map to the usage model of Workers, which are inherently stateless. In a normal multipart upload, the multipart upload is usually performed in one continuous execution of the client application. This is different from multipart uploads in a Worker, which will often be completed over multiple invocations of that Worker. This makes state management more challenging.

To overcome this, the state associated with a multipart upload, namely the uploadId and which parts have been uploaded, needs to be kept track of somewhere outside of the Worker.

In the example Worker and Python application described in this guide, the state of the multipart upload is tracked in the client application which sends requests to the Worker, with the necessary state contained in each request. Keeping track of the multipart state in the client application enables maximal flexibility and allows for parallel and unordered uploads of each part.

When keeping track of this state in the client is impossible, alternative designs can be considered. For example, you could track the uploadId and which parts have been uploaded in a Durable Object or other database.

Bucket locks

import { DashButton } from "~/components";

Bucket locks prevent the deletion and overwriting of objects in an R2 bucket for a specified period — or indefinitely. When enabled, bucket locks enforce retention policies on your objects, helping protect them from accidental or premature deletions.

Get started with bucket locks

Before getting started, you will need:

Enable bucket lock via dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you would like to add bucket lock rule to.

  3. Switch to the Settings tab, then scroll down to the Bucket lock rules card.

  4. Select Add rule and enter the rule name, prefix, and retention period.

  5. Select Save changes.

Enable bucket lock via Wrangler

  1. Install npm.
  2. Install Wrangler, the Developer Platform CLI.
  3. Log in to Wrangler with the wrangler login command.
  4. Add a bucket lock rule to your bucket by running the r2 bucket lock add command.
npx wrangler r2 bucket lock add <BUCKET_NAME> [OPTIONS]

Alternatively, you can set the entire bucket lock configuration for a bucket from a JSON file using the r2 bucket lock set command.

npx wrangler r2 bucket lock set <BUCKET_NAME> --file <FILE_PATH>

The JSON file should be in the format of the request body of the put bucket lock configuration API.

Enable bucket lock via API

For information about getting started with the Cloudflare API, refer to Make API calls. For information on required parameters and more examples of how to set bucket lock configuration, refer to the API documentation.

Below is an example of setting a bucket lock configuration (a collection of rules):

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/r2/buckets/<BUCKET_NAME>/lock" \
    -H "Authorization: Bearer <API_TOKEN>" \
    -H "Content-Type: application/json" \
    -d '{
        "rules": [
            {
                "id": "lock-logs-7d",
                "enabled": true,
                "prefix": "logs/",
                "condition": {
                    "type": "Age",
                    "maxAgeSeconds": 604800
                }
            },
            {
                "id": "lock-images-indefinite",
                "enabled": true,
                "prefix": "images/",
                "condition": {
                    "type": "Indefinite"
                }
            }
        ]
    }'

This request creates two rules:

:::note

If your bucket is setup with jurisdictional restrictions, you will need to pass a cf-r2-jurisdiction request header with that jurisdiction. For example, cf-r2-jurisdiction: eu.

:::

Get bucket lock rules for your R2 bucket

Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you would like to add bucket lock rule to.

  3. Switch to the Settings tab, then scroll down to the Bucket lock rules card.

Wrangler

To list bucket lock rules, run the r2 bucket lock list command:

npx wrangler r2 bucket lock list <BUCKET_NAME>

API

For more information on required parameters and examples of how to get bucket lock rules, refer to the API documentation.

Remove bucket lock rules from your R2 bucket

Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you would like to add bucket lock rule to.

  3. Switch to the Settings tab, then scroll down to the Bucket lock rules card.

  4. Locate the rule you want to remove, select the ... icon next to it, and then select Delete.

Wrangler

To remove a bucket lock rule, run the r2 bucket lock remove command:

npx wrangler r2 bucket lock remove <BUCKET_NAME> --id <RULE_ID>

API

To remove bucket lock rules via API, exclude them from your updated configuration and use the put bucket lock configuration API.

Bucket lock rules

A bucket lock configuration can include up to 1,000 rules. Each rule specifies which objects it covers (via prefix) and how long those objects must remain locked. You can:

If multiple rules apply to the same prefix or object key, the strictest (longest) retention requirement takes precedence.

Notes

Configure CORS

import {DashButton} from "~/components";

Cross-Origin Resource Sharing (CORS) is a standardized method that prevents domain X from accessing the resources of domain Y. It does so by using special headers in HTTP responses from domain Y, that allow your browser to verify that domain Y permits domain X to access these resources.

While CORS can help protect your data from malicious websites, CORS is also used to interact with objects in your bucket and configure policies on your bucket.

CORS is used when you interact with a bucket from a web browser, and you have two options:

Set a bucket to public: This option makes your bucket accessible on the Internet as read-only, which means anyone can request and load objects from your bucket in their browser or anywhere else. This option is ideal if your bucket contains images used in a public blog.

Presigned URLs: Allows anyone with access to the unique URL to perform specific actions on your bucket.

Prerequisites

Before you configure CORS, you must have:

Use CORS with a public bucket

To use CORS with a public bucket, ensure your bucket is set to allow public access.

Next, add a CORS policy to your bucket to allow the file to be shared.

Use CORS with a presigned URL

Presigned URLs allow temporary access to perform specific actions on your bucket without exposing your credentials. While presigned URLs handle authentication, you still need to configure CORS when making requests from a browser.

When a browser makes a request to a presigned URL on a different origin, the browser enforces CORS. Without a CORS policy, browser-based uploads and downloads using presigned URLs will fail, even though the presigned URL itself is valid.

Expired presigned URLs return a 403 ExpiredRequest response. R2 does not include CORS response headers on expired presigned URL responses, so browser JavaScript cannot read the error body. Refresh presigned URLs before they expire, or route requests through your application server if the browser needs to handle expiration errors directly.

To enable browser-based access with presigned URLs:

  1. Add a CORS policy to your bucket that allows requests from your application's origin.

  2. Set AllowedMethods to match the operations your presigned URLs perform, use GET, PUT, HEAD, and/or DELETE.

  3. Set AllowedHeaders to include any headers the client will send when using the presigned URL, such as headers for content type, checksums, caching, or custom metadata.

  4. (Optional) Set ExposeHeaders to allow your JavaScript to read response headers like ETag, which contains the object's hash and is useful for verifying uploads.

  5. (Optional) Set MaxAgeSeconds to cache the preflight response and reduce the number of preflight requests the browser makes.

The following example allows browser-based uploads from https://example.com with a Content-Type header:

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Use CORS with a custom domain

Custom domains connected to an R2 bucket with a CORS policy automatically return CORS response headers for cross-origin requests.

Cross-origin requests must include a valid Origin request header, for example, Origin: https://example.com. If you are testing directly or using a command-line tool such as curl, you will not see CORS Access-Control-* response headers unless the Origin request header is included in the request.

:::caution[Caching and CORS headers]

If you set a CORS policy on a bucket that is already serving traffic using a custom domain, any existing cached assets will not reflect the CORS response headers until they are refreshed in cache. Use Cache Purge to purge the cache for that hostname after making any CORS policy related changes.

:::

Add CORS policies from the dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Locate and select your bucket from the list.

  3. Select Settings.

  4. Under CORS Policy, select Add CORS policy.

  5. From the JSON tab, manually enter or copy and paste your policy into the text box.

  6. When you are done, select Save.

Your policy displays on the Settings page for your bucket.

Add CORS policies via Wrangler CLI

You can configure CORS rules using the Wrangler CLI.

  1. Create a JSON file with your CORS configuration:
{
  "rules": [
    {
      "allowed": {
        "origins": ["https://example.com"],
        "methods": ["GET"]
      }
    }
  ]
}
  1. Apply the CORS policy to your bucket:
npx wrangler r2 bucket cors set <BUCKET_NAME> --file cors.json
  1. Verify the CORS policy was applied:
npx wrangler r2 bucket cors list <BUCKET_NAME>

Response headers

The following fields in an R2 CORS policy map to HTTP response headers. These response headers are only returned when the incoming HTTP request is a valid CORS request.

Field Name Description Example
AllowedOrigins Specifies the value for the Access-Control-Allow-Origin header R2 sets when requesting objects in a bucket from a browser. If a website at www.test.com needs to access resources (e.g. fonts, scripts) on a custom domain of static.example.com, you would set https://www.test.com as an AllowedOrigin.
AllowedMethods Specifies the value for the Access-Control-Allow-Methods header R2 sets when requesting objects in a bucket from a browser. GET, POST, PUT
AllowedHeaders Specifies the value for the Access-Control-Allow-Headers header R2 sets when requesting objects in this bucket from a browser.Cross-origin requests that include custom headers (e.g. x-user-id) should specify these headers as AllowedHeaders. x-requested-by, User-Agent
ExposeHeaders Specifies the headers that can be exposed back, and accessed by, the JavaScript making the cross-origin request. If you need to access headers beyond the safelisted response headers, such as Content-Encoding or cf-cache-status, you must specify it here. Content-Encoding, cf-cache-status, Date
MaxAgeSeconds Specifies the amount of time (in seconds) browsers are allowed to cache CORS preflight responses. Browsers may limit this to 2 hours or less, even if the maximum value (86400) is specified. 3600

Example

This example shows a CORS policy added for a bucket that contains the Roboto-Light.ttf object, which is a font file.

The AllowedOrigins specify the web server being used, and localhost:3000 is the hostname where the web server is running. The AllowedMethods specify that only GET requests are allowed and can read objects in your bucket.

[
	{
		"AllowedOrigins": ["http://localhost:3000"],
		"AllowedMethods": ["GET"]
	}
]

In general, a good strategy for making sure you have set the correct CORS rules is to look at the network request that is being blocked by your browser.

Also note that CORS rule propagation can, in rare cases, take up to 30 seconds.

Common Issues

Create new buckets

You can create a bucket from the Cloudflare dashboard or using Wrangler.

:::note

Wrangler is a command-line tool for building with Cloudflare's developer products, including R2.

The R2 support in Wrangler allows you to manage buckets and perform basic operations against objects in your buckets. For more advanced use-cases, including bulk uploads or mirroring files from legacy object storage providers, we recommend rclone or an S3-compatible tool of your choice.

:::

Bucket-Level Operations

Create a bucket with the r2 bucket create command:

wrangler r2 bucket create your-bucket-name

:::note

The placeholder text is only for the example.

:::

List buckets in the current account with the r2 bucket list command:

wrangler r2 bucket list

To delete a bucket, you must first empty it and then delete it. For detailed instructions, refer to Delete buckets.

Notes

Data Access Logs

import { DashButton, Steps } from "~/components";

R2 Data Access Logs provide per-request records for object operations in a bucket. The logs are generally available for R2 buckets without a jurisdiction.

Data Access Logs differ from Audit Logs, which record bucket configuration changes. They also differ from R2 metrics, which provide aggregated request and storage data.

:::note Data Access Logs include requests with HTTP status codes below 400, including 304 Not Modified. Log delivery is asynchronous and best effort, and events may be delayed or omitted. Requests with status codes of 400 or greater are excluded. Do not rely on Data Access Logs as a complete record of bucket activity. :::

Supported interfaces

Data Access Logs record requests from these interfaces:

Interface Source
S3 Requests through the S3-compatible API.
API Object operations from the Cloudflare dashboard or API.
Workers Object operations through an R2 binding. These events include the Worker script name.
Public Requests to public buckets through r2.dev or custom domains. These requests are unauthenticated.

Logged operations

Data Access Logs record the following operations:

Category Operations
Read GetObject, HeadObject
Write and copy PutObject, CopyObject
List ListObjectsV1, ListObjectsV2
Multipart upload CreateMultipartUpload, UploadPart, UploadPartCopy, CompleteMultipartUpload, AbortMultipartUpload, ListMultipartUploads, ListParts
Delete DeleteObject, DeleteObjects, DeleteObjectsByPrefix

Bucket and configuration operations are not included. Failed requests with an HTTP status code of 400 or greater are also not included.

Turn on Data Access Logs

1. In the Cloudflare dashboard, go to the bucket you wish to enable.
  1. Under Data Access Logs, select Enabled.

R2 records new supported operations after you turn on Data Access Logs. Earlier operations are not added retroactively.

Data Access Logs have seven-day retention. Beginning December 1, 2026, they use Cloudflare Observability pricing.

View Data Access Logs

In the Data Access Logs section of the bucket settings, select View logs in Workers Observability. The Events view opens with the r2 dataset and current bucket selected for the previous hour.

Use the Query Builder to change the time range, add filters, or aggregate events.

Turn off Data Access Logs

1. In the Cloudflare dashboard, go to the bucket you wish to disable.
  1. In Data Access Logs, select Disabled.

Log fields

Every event can include these fields:

Field Description
$metadata.timestamp Event timestamp in Unix milliseconds.
$metadata.service Bucket name used as the service name.
$metadata.namespace Event namespace. The value is r2.
action R2 operation name.
actor.type Actor type: user or service. Public bucket requests use service.
actor.id Identifier for the authenticated actor. Public bucket requests use public.
actor.email Email address for a user actor.
actor.accessKeyId Access key ID for a request authenticated with AWS Signature Version 4.
bucket Target bucket name.
interface Request interface: S3, API, Workers, or Public.
request.bytes Request Content-Length value in bytes.
response.bytes Response Content-Length value in bytes.
response.errorCode Response error code. This value is NotModified for a 304 response and null for a 2xx response.
response.errorMessage Response error message. This value describes a 304 response and is null for a 2xx response.
requestMetadata.colo Cloudflare data center code, or XXX when the data center is unavailable.

S3-compatible API, Cloudflare API, and public bucket events can also include these fields:

Field Description
request.method HTTP request method.
request.uri Request path.
response.status HTTP response status code.
requestMetadata.ip Client IP address.
requestMetadata.userAgent Client user agent.

Workers binding events include this additional field:

Field Description
scriptName Name of the Worker script that triggered the operation.

Workers binding events do not include the HTTP method, URI, response status, client IP address, or user agent.

An event can include these operation-specific fields:

Field Description
resource.key Object key.
resource.type Resource type: object or multipart_upload.
resource.size Object size in bytes when available.
resource.uploadId Upload ID for a multipart upload.
sourceResource Source bucket, key, and resource type for a copy operation.
prefix Prefix used by a list or prefix-delete operation.
delimiter Delimiter used by a list operation.
maxKeys Maximum keys requested by an object list operation.
maxUploads Maximum uploads requested by a multipart upload list operation.
objects Object keys included in a bulk delete operation.

The request.bytes and response.bytes fields reflect Content-Length values, not exact transferred-byte measurements. A byte count or resource size of 0 can mean either zero bytes or that the value was unavailable when R2 created the event.

Delete buckets

import { DashButton, LinkCard } from "~/components";

To delete an R2 bucket, you must first remove all objects from it. Once the bucket is empty, you can delete it.

Empty a bucket

Emptying a bucket deletes every object inside it. The bucket itself and its configuration (lifecycle rules, CORS, event notifications, custom domains) are preserved.

Objects removed during this process cannot be recovered. If your bucket has bucket lock rules, you must remove them before emptying the bucket.

You can empty a bucket in various ways.

Dashboard

The dashboard provides an Empty Bucket action that handles this for you, regardless of how many objects the bucket contains. For large buckets, the operation runs in the background and the dashboard displays progress until all objects have been removed.

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you want to empty.

  3. Go to the Settings tab.

  4. Scroll to the Empty Bucket section.

  5. Select Empty.

  6. Confirm the action in the dialog that appears.

:::caution

Emptying a bucket is irreversible. All objects in the bucket are permanently deleted.

:::

Object lifecycle rules

You can configure an object lifecycle rule that expires all objects in the bucket.

  1. Add a lifecycle rule with no prefix filter and an expiration of 1 day.
  2. Wait for the rule to take effect. Objects are typically removed within 24 hours, but large buckets may take longer.
  3. After the bucket is empty, remove the lifecycle rule.

:::note

Objects uploaded while the lifecycle rule is active are also subject to expiration. Remove the rule as soon as the bucket is empty to avoid deleting new objects.

:::

Wrangler

Add a lifecycle rule that expires all objects after 1 day:

npx wrangler r2 bucket lifecycle add <BUCKET_NAME> --expire-days 1

After the bucket is empty, remove the rule:

npx wrangler r2 bucket lifecycle remove <BUCKET_NAME> --id <RULE_ID>

For the full list of lifecycle commands, refer to Wrangler R2 commands.

You can also configure lifecycle rules using the S3 API or the dashboard. For more information, refer to Object lifecycles.

Other approaches

You can also write a script that lists and deletes objects in batches using the S3 API or Workers API, or use rclone to remove objects from the command line.

Delete a bucket

Once a bucket is empty, you can permanently delete it. Deleting a bucket removes the bucket and all of its configuration, including lifecycle rules, CORS settings, event notifications, and custom domains.

You can delete a bucket in various ways.

Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you want to delete.

  3. Go to the Settings tab.

  4. Scroll to the Delete Bucket section. If the bucket is not empty, select Empty Bucket first to clear all objects.

  5. Select Delete.

  6. Confirm the action in the dialog that appears.

Wrangler

Use the r2 bucket delete command to delete an empty bucket:

npx wrangler r2 bucket delete <BUCKET_NAME>

The command fails if the bucket still contains objects. Empty the bucket before running this command.

API

Use the delete bucket API endpoint to delete an empty bucket:

curl -X DELETE "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/r2/buckets/<BUCKET_NAME>" \
  -H "Authorization: Bearer <API_TOKEN>"

If the bucket is in a jurisdiction, include the cf-r2-jurisdiction header:

curl -X DELETE "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/r2/buckets/<BUCKET_NAME>" \
  -H "Authorization: Bearer <API_TOKEN>" \
  -H "cf-r2-jurisdiction: eu"

Behavior

Event notifications

import { DashButton } from "~/components";

Event notifications send messages to your queue when data in your R2 bucket changes. You can consume these messages with a consumer Worker or pull over HTTP from outside of Cloudflare Workers.

Get started with event notifications

Prerequisites

Before getting started, you will need:

Enable event notifications via Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select the bucket you'd like to add an event notification rule to.

  3. Switch to the Settings tab, then scroll down to the Event notifications card.

  4. Select Add notification and choose the queue you'd like to receive notifications and the type of events that will trigger them.

  5. Select Add notification.

Enable event notifications via Wrangler

Set up Wrangler

To begin, install npm. Then install Wrangler, the Developer Platform CLI.

Enable event notifications on your R2 bucket

Log in to Wrangler with the wrangler login command. Then add an event notification rule to your bucket by running the r2 bucket notification create command.

npx wrangler r2 bucket notification create <BUCKET_NAME> --event-type <EVENT_TYPE> --queue <QUEUE_NAME>

To add filtering based on prefix or suffix use the --prefix or --suffix flag, respectively.

# Filter using prefix
$ npx wrangler r2 bucket notification create <BUCKET_NAME> --event-type <EVENT_TYPE> --queue <QUEUE_NAME> --prefix "<PREFIX_VALUE>"

# Filter using suffix
$ npx wrangler r2 bucket notification create <BUCKET_NAME> --event-type <EVENT_TYPE> --queue <QUEUE_NAME> --suffix "<SUFFIX_VALUE>"

# Filter using prefix and suffix. Both the conditions will be used for filtering
$ npx wrangler r2 bucket notification create <BUCKET_NAME> --event-type <EVENT_TYPE> --queue <QUEUE_NAME> --prefix "<PREFIX_VALUE>" --suffix "<SUFFIX_VALUE>"

For a more complete step-by-step example, refer to the Log and store upload events in R2 with event notifications example.

Event notification rules

Event notification rules determine the event types that trigger notifications and optionally enable filtering based on object prefix and suffix. You can have up to 100 event notification rules per R2 bucket.

Event types

Event type Description Trigger actions
object-create Triggered when new objects are created or existing objects are overwritten.
  • PutObject
  • CopyObject
  • CompleteMultipartUpload
object-delete Triggered when an object is explicitly removed from the bucket.
  • DeleteObject
  • LifecycleDeletion

Message format

Queue consumers receive notifications as Messages. The following is an example of the body of a message that a consumer Worker will receive:

{
	"account": "3f4b7e3dcab231cbfdaa90a6a28bd548",
	"action": "CopyObject",
	"bucket": "my-bucket",
	"object": {
		"key": "my-new-object",
		"size": 65536,
		"eTag": "c846ff7a18f28c2e262116d6e8719ef0"
	},
	"eventTime": "2024-05-24T19:36:44.379Z",
	"copySource": {
		"bucket": "my-bucket",
		"object": "my-original-object"
	}
}

Properties

Property Type Description
account String The Cloudflare account ID that the event is associated with.
action String The type of action that triggered the event notification. Example actions include: PutObject, CopyObject, CompleteMultipartUpload, DeleteObject.
bucket String The name of the bucket where the event occurred.
object Object A nested object containing details about the object involved in the event.
object.key String The key (or name) of the object within the bucket.
object.size Number The size of the object in bytes. Note: not present for object-delete events.
object.eTag String The entity tag (eTag) of the object. Note: not present for object-delete events.
eventTime String The time when the action that triggered the event occurred.
copySource Object A nested object containing details about the source of a copied object. Note: only present for events triggered by CopyObject.
copySource.bucket String The bucket that contained the source object.
copySource.object String The name of the source object.

Notes

Buckets

import { DirectoryListing } from "~/components"

With object storage, all of your objects are stored in buckets. Buckets do not contain folders that group the individual files, but instead, buckets have a flat structure which simplifies the way you access and retrieve the objects in your bucket.

Learn more about bucket level operations from the items below.

Local uploads

import { Tabs, TabItem, DashButton } from "/components"; import R2LocalUploadsDiagram from "/components/cf/R2LocalUploadsDiagram.astro";

You can enable Local Uploads on your bucket to improve the performance of upload requests when clients upload data from a different region than your bucket. Local Uploads writes object data to a nearby location, then asynchronously copies it to your bucket. Data is available immediately and remains strongly consistent.

How it works

The following sections describe how R2 handles upload requests with and without Local Uploads enabled.

Without Local Uploads

When a client uploads an object to your R2 bucket, the object data must travel from the client to the storage infrastructure of your bucket. This behavior can result in higher latency and lower reliability when your client is in a different region than the bucket. Refer to How R2 works for details.

With Local Uploads

When you make an upload request (i.e. PutObject and UploadPart) to a bucket with Local Uploads enabled, there are two cases that are handled:

When to use local uploads

Local uploads are built for workloads that receive a lot of uploads originating from different geographic regions than where your bucket is located. This feature is ideal when:

To understand the geographic distribution of where your read and write requests are initiated:

  1. Log in to the Cloudflare dashboard, and go to R2 Overview.

  2. Select your bucket.

  3. Select Metrics and view the Request Distribution chart.

Read latency considerations

When local uploads is enabled, uploaded data may temporarily reside near the client before replication completes.

If your workload requires immediate read after write, consider where your read requests originate. Reads from the uploader's region will be fast, while reads from near the bucket's region may experience cross-region latency until replication completes.

Jurisdiction restriction

Local uploads are not supported for buckets with jurisdictional restrictions, because it requires temporarily routing data through locations outside the bucket’s region.

Enable local uploads

When you enable Local Uploads, existing uploads will complete as expected with no interruption to traffic.

  1. Log in to the Cloudflare dashboard, and go to R2 Overview.

  2. Select your bucket.

  3. Select Settings.

  4. Under Local Uploads, select Enable.

Run the following command:

npx wrangler r2 bucket local-uploads enable <BUCKET_NAME>

Disable local uploads

You can disable local uploads at any time. Existing requests made with local uploads will complete replication with no interruption to your traffic.

  1. Log in to the Cloudflare dashboard, and go to R2 Overview.

  2. Select your bucket.

  3. Select Settings.

  4. Under Local Uploads, select Disable.

Run the following command:

npx wrangler r2 bucket local-uploads disable <BUCKET_NAME>

Pricing

There is no additional cost to enable local uploads. Upload requests made with this feature enabled incur the standard Class A operation costs, same as upload requests made without local uploads.

Object lifecycles

import { DashButton } from "~/components";

Object lifecycles determine the retention period of objects uploaded to your bucket and allow you to specify when objects should transition from Standard storage to Infrequent Access storage.

A lifecycle configuration is a collection of lifecycle rules that define actions to apply to objects during their lifetime.

For example, you can create an object lifecycle rule to delete objects after 90 days, or you can set a rule to transition objects to Infrequent Access storage after 30 days.

Behavior

Configure lifecycle rules for your bucket

When you create an object lifecycle rule, you can specify which prefix you would like it to apply to.

Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Locate and select your bucket from the list.

  3. From the bucket page, select Settings.

  4. Under Object Lifecycle Rules, select Add rule.

  5. Fill out the fields for the new rule.

  6. When you are done, select Save changes.

Wrangler

  1. Install npm.
  2. Install Wrangler, the Developer Platform CLI.
  3. Log in to Wrangler with the wrangler login command.
  4. Add a lifecycle rule to your bucket by running the r2 bucket lifecycle add command.
npx wrangler r2 bucket lifecycle add <BUCKET_NAME> [OPTIONS]

Alternatively you can set the entire lifecycle configuration for a bucket from a JSON file using the r2 bucket lifecycle set command.

npx wrangler r2 bucket lifecycle set <BUCKET_NAME> --file <FILE_PATH>

The JSON file should be in the format of the request body of the put object lifecycle configuration API.

S3 API

Below is an example of configuring a lifecycle configuration (a collection of lifecycle rules) with different sets of rules for different potential use cases.

import {
	S3Client,
	PutBucketLifecycleConfigurationCommand,
} from "@aws-sdk/client-s3";

const client = new S3Client({
	endpoint: "https://<account_id>.r2.cloudflarestorage.com",
	credentials: {
		accessKeyId: "<access_key_id>",
		secretAccessKey: "<access_key_secret>",
	},
	region: "auto",
});
await client.send(
	new PutBucketLifecycleConfigurationCommand({
		Bucket: "testBucket",
		LifecycleConfiguration: {
			Rules: [
				// Example: deleting objects on a specific date
				// Delete 2019 documents in 2024
				{
					ID: "Delete 2019 Documents",
					Status: "Enabled",
					Filter: {
						Prefix: "2019/",
					},
					Expiration: {
						Date: new Date("2024-01-01"),
					},
				},
				// Example: transitioning objects to Infrequent Access storage by age
				// Transition objects older than 30 days to Infrequent Access storage
				{
					ID: "Transition Objects To Infrequent Access",
					Status: "Enabled",
					Transitions: [
						{
							Days: 30,
							StorageClass: "STANDARD_IA",
						},
					],
				},
				// Example: deleting objects by age
				// Delete logs older than 90 days
				{
					ID: "Delete Old Logs",
					Status: "Enabled",
					Filter: {
						Prefix: "logs/",
					},
					Expiration: {
						Days: 90,
					},
				},
				// Example: abort all incomplete multipart uploads after a week
				{
					ID: "Abort Incomplete Multipart Uploads",
					Status: "Enabled",
					AbortIncompleteMultipartUpload: {
						DaysAfterInitiation: 7,
					},
				},
				// Example: abort user multipart uploads after a day
				{
					ID: "Abort User Incomplete Multipart Uploads",
					Status: "Enabled",
					Filter: {
						Prefix: "useruploads/",
					},
					AbortIncompleteMultipartUpload: {
						// For uploads matching the prefix, this rule will take precedence
						// over the one above due to its earlier expiration.
						DaysAfterInitiation: 1,
					},
				},
			],
		},
	}),
);

Get lifecycle rules for your bucket

Wrangler

To get the list of lifecycle rules associated with your bucket, run the r2 bucket lifecycle list command.

npx wrangler r2 bucket lifecycle list <BUCKET_NAME>

S3 API

import {
	S3Client,
	GetBucketLifecycleConfigurationCommand,
} from "@aws-sdk/client-s3";

// Configure the S3 client to talk to R2.
const client = new S3Client({
	endpoint: "https://<account_id>.r2.cloudflarestorage.com",
	credentials: {
		accessKeyId: "<access_key_id>",
		secretAccessKey: "<access_key_secret>",
	},
	region: "auto",
});

// Get lifecycle configuration for bucket
console.log(
	await client.send(
		new GetBucketLifecycleConfigurationCommand({
			Bucket: "bucketName",
		}),
	),
);

Delete lifecycle rules from your bucket

Dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Locate and select your bucket from the list.

  3. From the bucket page, select Settings.

  4. Under Object lifecycle rules, select the rules you would like to delete.

  5. When you are done, select Delete rule(s).

Wrangler

To remove a specific lifecycle rule from your bucket, run the r2 bucket lifecycle remove command.

npx wrangler r2 bucket lifecycle remove <BUCKET_NAME> --id <RULE_ID>

S3 API

import { S3Client, DeleteBucketLifecycleCommand } from "@aws-sdk/client-s3";

// Configure the S3 client to talk to R2.
const client = new S3Client({
	endpoint: "https://<account_id>.r2.cloudflarestorage.com",
	credentials: {
		accessKeyId: "<access_key_id>",
		secretAccessKey: "<access_key_secret>",
	},
	region: "auto",
});

// Delete lifecycle configuration for bucket
await client.send(
	new DeleteBucketLifecycleCommand({
		Bucket: "bucketName",
	}),
);

Public buckets

import { Render } from "~/components";

Public Bucket is a feature that allows users to expose the contents of their R2 buckets directly to the Internet. By default, buckets are never publicly accessible and will always require explicit user permission to enable.

Public buckets can be set up in either of two ways:

These options can be used independently. Enabling custom domains does not require enabling r2.dev access.

To use features like WAF custom rules, caching, access controls, or Bot Management, you must configure your bucket behind a custom domain. These capabilities are not available when using the r2.dev development url.

:::note

Currently, public buckets do not let you list the bucket contents at the root of your (sub) domain.

:::

Custom domains

Caching

Domain access through a custom domain allows you to use Cloudflare Cache to accelerate access to your R2 bucket.

Configure your cache to use Smart Tiered Cache to have a single upper-tier data center next to your R2 bucket.

:::note

By default, only certain file types are cached. To cache all files in your bucket, you must set a Cache Everything page rule.

For more information on default Cache behavior and how to customize it, refer to Default Cache Behavior

:::

Access control

To restrict access to your custom domain's bucket, use Cloudflare's existing security products.

:::caution

Disable public access to your r2.dev subdomain when using products like WAF or Cloudflare Access. If you do not disable public access, your bucket will remain publicly available through your r2.dev subdomain.

:::

Minimum TLS Version & Cipher Suites

To customise the minimum TLS version or cipher suites of a custom hostname of an R2 bucket, you can issue an API call to edit R2 custom domain settings. You will need to add the optional minTLS and ciphers parameters to the request body. For a list of the cipher suites you can specify, refer to Supported cipher suites.

Add your domain to Cloudflare

The domain being used must have been added as a zone in the same account as the R2 bucket.

Once the domain exists in your Cloudflare account (regardless of setup type), you can link it to your bucket.

Connect a bucket to a custom domain

To view the added DNS record, select ... next to the connected domain and select Manage DNS.

:::note

If the zone is on an Enterprise plan, make sure that you release the zone hold before adding the custom domain.

A zone hold would prevent the custom subdomain from activating.

:::

Disable domain access

Disabling a domain will turn off public access to your bucket through that domain. Access through other domains or the managed r2.dev subdomain are unaffected. The specified domain will also remain connected to R2 until you remove it or delete the bucket.

To disable a domain:

  1. In R2, select the bucket you want to modify.
  2. On the bucket page, Select Settings, go to Custom Domains.
  3. Next to the domain you want to disable, select ... and Disable domain.
  4. The badge under Access to Bucket will update to Not allowed.

Remove domain

Removing a custom domain will disconnect it from your bucket and delete its configuration from the dashboard. Your bucket will remain publicly accessible through any other enabled access method, but the domain will no longer appear in the connected domains list.

To remove a domain:

  1. In R2, select the bucket you want to modify.
  2. On the bucket page, Select Settings, go to Custom Domains.
  3. Next to the domain you want to disable, select ... and Remove domain.
  4. Select Remove domain in the confirmation window. This step also removes the CNAME record pointing to the domain. You can always add the domain again.

Public development URL

Expose the contents of this R2 bucket to the internet through a Cloudflare-managed r2.dev subdomain. This endpoint is intended for non-production traffic.

:::note

Public access through r2.dev subdomains is rate-limited and should only be used for development purposes.

To enable access management, cache, and bot management features, you must set up a custom domain when enabling public access to your bucket.

Avoid creating a CNAME record pointing to the r2.dev subdomain. This is an unsupported access path, and we cannot guarantee consistent reliability or performance. For production use, add your domain to Cloudflare instead. :::

Enable public development URL

When you enable public development URL access for your bucket, its contents become available on the internet through a Cloudflare-managed r2.dev subdomain.

To enable access through r2.dev for your buckets:

  1. In R2, select the bucket you want to modify.
  2. On the bucket page, select Settings.
  3. Under Public Development URL, select Enable.
  4. In Allow Public Access?, confirm your choice by typing allow to confirm and select Allow.
  5. You can now access the bucket and its objects using the Public Bucket URL.

To verify that your bucket is publicly accessible, check that Public URL Access shows Allowed in your bucket settings.

Disable public development URL

Disabling public development URL access removes your bucket's exposure through the r2.dev subdomain. The bucket and its objects will no longer be accessible via the Public Bucket URL.

If you have connected other domains, the bucket will remain accessible for those domains.

To disable public access for your bucket:

  1. In R2, select the bucket you want to modify.
  2. On the bucket page, select Settings.
  3. Under Public Development URL, select Disable.
  4. In Disallow Public Access?, type disallow to confirm and select Disallow.

Storage classes

import { Badge, Tabs, TabItem } from "~/components"

Storage classes allow you to trade off between the cost of storage and the cost of accessing data. Every object stored in R2 has an associated storage class.

All storage classes share the following characteristics:

Available storage classes

Storage class Minimum storage duration Data retrieval fees (processing) Egress fees (data transfer to Internet)
Standard None None None
Infrequent Access 30 days Yes None

For more information on how storage classes impact pricing, refer to Pricing.

Standard storage

Standard storage is designed for data that is accessed frequently. This is the default storage class for new R2 buckets unless otherwise specified.

Example use cases

Infrequent Access storage

Infrequent Access storage is ideal for data that is accessed less frequently. This storage class offers lower storage cost compared to Standard storage, but includes retrieval fees and a 30 day minimum storage duration requirement.

:::note For objects stored in Infrequent Access storage, you will be charged for the object for the minimum storage duration even if the object was deleted, moved, or replaced before the specified duration. :::

Example use cases

Set default storage class for buckets

By setting the default storage class for a bucket, all objects uploaded into the bucket will automatically be assigned the selected storage class unless otherwise specified. Default storage class can be changed after bucket creation in the Dashboard.

To learn more about creating R2 buckets, refer to Create new buckets.

Set storage class for objects

Specify storage class during object upload

To learn more about how to specify the storage class for new objects, refer to the Workers API and S3 API documentation.

Use object lifecycle rules to transition objects to Infrequent Access storage

:::note Once an object is stored in Infrequent Access, it cannot be transitioned to Standard Access using lifecycle policies. :::

To learn more about how to transition objects from Standard storage to Infrequent Access storage, refer to Object lifecycles.

Change storage class for objects

You can change the storage class of an object which is already stored in R2 using the CopyObject API.

Use the x-amz-storage-class header to change between STANDARD and STANDARD_IA.

An example of switching an object from STANDARD to STANDARD_IA using aws cli is shown below:

aws s3api copy-object \
  --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
  --bucket bucket-name \
  --key path/to/object.txt \
  --copy-source /bucket-name/path/to/object.txt \
  --storage-class STANDARD_IA

Data migration

Quickly and easily migrate data from other cloud providers to R2. Explore each option further by navigating to their respective documentation page.

Super Slurper Quickly migrate large amounts of data from other cloud providers to R2.
  • For one-time, comprehensive transfers.
Sippy Incremental data migration, populating your R2 bucket as objects are requested.
  • For gradual migration that avoids upfront egress fees.
  • To start serving frequently accessed objects from R2 without a full migration.

For information on how to leverage these tools effectively, refer to Migration Strategies

Migration Strategies

import { Render, FileTree } from "~/components";

You can use a combination of Super Slurper and Sippy to effectively migrate all objects with minimal downtime.

When the source bucket is actively being read from / written to

  1. Enable Sippy and start using the R2 bucket in your application.
    • This copies objects from your previous bucket into the R2 bucket on demand when they are requested by the application.
    • New uploads will go to the R2 bucket.
  2. Use Super Slurper to trigger a one-off migration to copy the remaining objects into the R2 bucket.
    • In the Destination R2 bucket > Overwrite files?, select "Skip existing".

When the source bucket is not being read often

  1. Use Super Slurper to copy all objects to the R2 bucket.
    • Note that Super Slurper may skip some objects if they are uploaded after it lists the objects to be copied.
  2. Enable Sippy on your R2 bucket, then start using the R2 bucket in your application.
    • New uploads will go to the R2 bucket.
    • Objects which were uploaded while Super Slurper was copying the objects will be copied on-demand (by Sippy) when they are requested by the application.

Optimizing your Slurper data migration performance

For an account, you can run three concurrent Slurper migration jobs at any given time, and each Slurper migration job can process a set amount of requests per second.

To increase overall throughput and reliability, we recommend splitting your migration into smaller, concurrent jobs using the prefix (or bucket subpath) option.

When creating a migration job:

  1. Go to the Source bucket step.
  2. Under Define rules, in Bucket subpath, specify subpaths to divide your data by prefix.
  3. Complete the data migration set up.

For example, suppose your source bucket contains:

- photos - 2024 - file1.jpg - file2.jpg - 2023 - file3.jpg - 2019 - file4.jpg

You can create separate jobs with prefixes such as:

Each prefix runs as an independent migration job, allowing Slurper to transfer data in parallel. This improves total transfer speed and ensures that a failure in one job does not interrupt the others.

Sippy

import { Render, DashButton, Steps } from "~/components";

Sippy is a data migration service that allows you to copy data from other cloud providers to R2 as the data is requested, without paying unnecessary cloud egress fees typically associated with moving large amounts of data.

Migration-specific egress fees are reduced by leveraging requests within the flow of your application where you would already be paying egress fees to simultaneously copy objects to R2.

How it works

When enabled for an R2 bucket, Sippy implements the following migration strategy across Workers, S3 API, and public buckets:

When is Sippy useful?

Using Sippy as part of your migration strategy can be a good choice when:

If you are looking to migrate all of your data from an existing cloud provider to R2 at one time, we recommend using Super Slurper.

Get started with Sippy

Before getting started, you will need:

Enable Sippy via the Dashboard

1. In the Cloudflare dashboard, go to the **R2 object storage** page. 2. Select the bucket you would like to migrate objects to. 3. Switch to the **Settings** tab, then scroll down to the **On Demand Migration** card. 4. Select **Enable** and enter details for the bucket you would like to migrate objects from. The credentials you enter must have permissions to read from this bucket. Cloudflare also recommends scoping your credentials to only allow reads from this bucket. 5. Select **Enable**.

Enable Sippy via Wrangler

Set up Wrangler

To begin, install npm. Then install Wrangler, the Developer Platform CLI.

Enable Sippy on your R2 bucket

Log in to Wrangler with the wrangler login command. Then run the r2 bucket sippy enable command:

npx wrangler r2 bucket sippy enable "<BUCKET_NAME>"

This will prompt you to select between supported object storage providers and lead you through setup.

Enable Sippy via API

For information on required parameters and examples of how to enable Sippy, refer to the API documentation. For information about getting started with the Cloudflare API, refer to Make API calls.

:::note

If your bucket is setup with jurisdictional restrictions, you will need to pass a cf-r2-jurisdiction request header with that jurisdiction. For example, cf-r2-jurisdiction: eu.

:::

View migration metrics

When enabled, Sippy exposes metrics that help you understand the progress of your ongoing migrations.

Metric Description
Requests served by Sippy The percentage of overall requests served by R2 over a period of time. A higher percentage indicates that fewer requests need to be made to the source bucket.
Data migrated by Sippy The amount of data that has been copied from the source bucket to R2 over a period of time. Reported in bytes.

To view current and historical metrics:

1. In the Cloudflare dashboard, go to the **R2 object storage** page. 2. Select your bucket. 3. Select the **Metrics** tab.

You can optionally select a time window to query. This defaults to the last 24 hours.

Disable Sippy on your R2 bucket

Dashboard

1. In the Cloudflare dashboard, go to the **R2 object storage** page. 2. Select the bucket you'd like to disable Sippy for. 3. Switch to the **Settings** tab and scroll down to the **On Demand Migration** card. 4. Press **Disable**.

Wrangler

To disable Sippy, run the r2 bucket sippy disable command:

npx wrangler r2 bucket sippy disable "<BUCKET_NAME>"

API

For more information on required parameters and examples of how to disable Sippy, refer to the API documentation.

Supported cloud storage providers

Cloudflare currently supports copying data from the following cloud object storage providers to R2:

R2 API interactions

When Sippy is enabled, it changes the behavior of certain actions on your R2 bucket across Workers, S3 API, and public buckets.

Action New behavior
GetObject Calls to GetObject will first attempt to retrieve the object from your R2 bucket. If the object is not present, the object will be served from the source storage bucket and simultaneously uploaded to the requested R2 bucket.

Additional considerations:
  • Modifications to objects in the source bucket will not be reflected in R2 after the initial copy. Once an object is stored in R2, it will not be re-retrieved and updated.
  • Only user-defined metadata that is prefixed by x-amz-meta- in the HTTP response will be migrated. Remaining metadata will be omitted.
  • For larger objects (greater than 199 MiB), multiple GET requests may be required to fully copy the object to R2.
  • If there are multiple simultaneous GET requests for an object which has not yet been fully copied to R2, Sippy may fetch the object from the source storage bucket multiple times to serve those requests.
HeadObject Behaves similarly to GetObject, but only retrieves object metadata. Will not copy objects to the requested R2 bucket.
PutObject No change to behavior. Calls to PutObject will add objects to the requested R2 bucket.
DeleteObject No change to behavior. Calls to DeleteObject will delete objects in the requested R2 bucket.

Additional considerations:
  • If deletes to objects in R2 are not also made in the source storage bucket, subsequent GetObject requests will result in objects being retrieved from the source bucket and copied to R2.

Actions not listed above have no change in behavior. For more information, refer to Workers API reference or S3 API compatibility.

Create credentials for storage providers

Amazon S3

To copy objects from Amazon S3, Sippy requires access permissions to your bucket. While you can use any AWS Identity and Access Management (IAM) user credentials with the correct permissions, Cloudflare recommends you create a user with a narrow set of permissions.

To create credentials with the correct permissions:

1. Log in to your AWS IAM account. 2. Create a policy with the following format and replace `` with the bucket you want to grant access to: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:ListBucket*", "s3:GetObject*"], "Resource": [ "arn:aws:s3:::", "arn:aws:s3:::/*" ] } ] } ``` 3. Create a new user and attach the created policy to that user.

You can now use both the Access Key ID and Secret Access Key when enabling Sippy.

Google Cloud Storage

To copy objects from Google Cloud Storage (GCS), Sippy requires access permissions to your bucket. Cloudflare recommends using the Google Cloud predefined Storage Object Viewer role.

To create credentials with the correct permissions:

1. Log in to your Google Cloud console. 2. Go to **IAM & Admin** > **Service Accounts**. 3. Create a service account with the predefined `Storage Object Viewer` role. 4. Go to the **Keys** tab of the service account you created. 5. Select **Add Key** > **Create a new key** and download the JSON key file.

You can now use this JSON key file when enabling Sippy via Wrangler or API.

Azure Blob Storage

To copy objects from Azure Blob Storage, Sippy requires the name of your Azure Storage account, the container to copy from, and either an account key or a shared access signature (SAS) token. Provide exactly one of the two credential types. Sippy needs read and list permissions on the container.

To use an account key:

1. Log in to the Azure portal and go to your storage account. 2. Under **Security + networking**, select **Access keys**. 3. Copy your storage account name and one of the listed keys.

To use a SAS token instead, Cloudflare recommends scoping it to only the container you are migrating:

1. Log in to the Azure portal and go to your storage account. 2. Under **Data storage**, select **Containers**, then open the container you want to migrate from. 3. Select **Shared access tokens**, grant **Read** and **List** permissions, then select **Generate SAS token and URL**. 4. Copy the generated SAS token.

You can now use the account name, container name, and either the account key or SAS token when enabling Sippy.

S3-compatible storage

To copy objects from an S3-compatible storage provider, Sippy requires the S3 API endpoint URL for your bucket, along with an Access Key ID and Secret Access Key that can read from it. Cloudflare recommends scoping these credentials to only allow reads from the bucket you are migrating.

Refer to the documentation for your storage provider to find the S3 API endpoint for your bucket and to create read-only access credentials.

You can now use the bucket URL, Access Key ID, and Secret Access Key when enabling Sippy.

Caveats

ETags

<Render file="migrator-etag-caveat" params={{ one: "Sippy" }} product="r2" />

Super Slurper

import { InlineBadge, Render, DashButton } from "~/components";

Super Slurper allows you to quickly and easily copy objects from other cloud providers to an R2 bucket of your choice.

Migration jobs:

When to use Super Slurper

Using Super Slurper as part of your strategy can be a good choice if the cloud storage bucket you are migrating consists primarily of objects less than 1 TB. Objects greater than 1 TB will be skipped and need to be copied separately.

For migration use cases that do not meet the above criteria, we recommend using tools such as rclone.

Use Super Slurper to migrate data to R2

  1. In the Cloudflare dashboard, go to the R2 data migration page.

  2. Select Migrate files.

  3. Select the source cloud storage provider that you will be migrating data from.

  4. Enter your source bucket name and associated credentials and select Next.

  5. Enter your R2 bucket name and associated credentials and select Next.

  6. After you finish reviewing the details of your migration, select Migrate files.

You can view the status of your migration job at any time by selecting your migration from Data Migration page.

Source bucket options

Bucket sub path (optional)

This setting specifies the prefix within the source bucket where objects will be copied from.

Destination R2 bucket options

Overwrite files?

This setting determines what happens when an object being copied from the source storage bucket matches the path of an existing object in the destination R2 bucket. There are two options:

Supported cloud storage providers

Cloudflare currently supports copying data from the following cloud object storage providers to R2:

Tested S3-compatible storage providers

The following S3-compatible storage providers have been tested and verified to work with Super Slurper:

Super Slurper should support transfers from all S3-compatible storage providers, but the ones listed have been explicitly tested.

:::note

Have you tested and verified another S3-compatible provider? Open a pull request or create a GitHub issue.

:::

Create credentials for storage providers

Amazon S3

To copy objects from Amazon S3, Super Slurper requires access permissions to your S3 bucket. While you can use any AWS Identity and Access Management (IAM) user credentials with the correct permissions, Cloudflare recommends you create a user with a narrow set of permissions.

To create credentials with the correct permissions:

  1. Log in to your AWS IAM account.
  2. Create a policy with the following format and replace <BUCKET_NAME> with the bucket you want to grant access to:
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Action": ["s3:Get*", "s3:List*"],
			"Resource": ["arn:aws:s3:::<BUCKET_NAME>", "arn:aws:s3:::<BUCKET_NAME>/*"]
		}
	]
}
  1. Create a new user and attach the created policy to that user.

You can now use both the Access Key ID and Secret Access Key when defining your source bucket.

Google Cloud Storage

To copy objects from Google Cloud Storage (GCS), Super Slurper requires access permissions to your GCS bucket. You can use the Google Cloud predefined Storage Admin role, but Cloudflare recommends creating a custom role with a narrower set of permissions.

To create a custom role with the necessary permissions:

  1. Log in to your Google Cloud console.
  2. Go to IAM & Admin > Roles.
  3. Find the Storage Object Viewer role and select Create role from this role.
  4. Give your new role a name.
  5. Select Add permissions and add the storage.buckets.get permission.
  6. Select Create.

To create credentials with your custom role:

  1. Log in to your Google Cloud console.
  2. Go to IAM & Admin > Service Accounts.
  3. Create a service account with the your custom role.
  4. Go to the Keys tab of the service account you created.
  5. Select Add Key > Create a new key and download the JSON key file.

You can now use this JSON key file when enabling Super Slurper.

Caveats

ETags

<Render file="migrator-etag-caveat" params={{ one: "Super Slurper" }} product="r2" />

Archive storage classes

Objects stored using AWS S3 archival storage classes will be skipped and need to be copied separately. Specifically:

Authenticate against R2 API using auth tokens

import { PackageManagers, Tabs, TabItem } from '~/components';

The following example shows how to authenticate against R2 using the S3 API and an API token.

:::note For providing secure access to bucket objects for anonymous users, we recommend using pre-signed URLs instead.

Pre-signed URLs do not require users to be a member of your organization and enable direct programmatic access to R2. :::

Ensure you have set the following environment variables prior to running either example. Refer to Authentication for more information.

export AWS_REGION=auto
export AWS_ENDPOINT_URL=https://<account_id>.r2.cloudflarestorage.com
export AWS_ACCESS_KEY_ID=your_access_key_id
export AWS_SECRET_ACCESS_KEY=your_secret_access_key
Install the `@aws-sdk/client-s3` package for the S3 API:
<PackageManagers pkg="@aws-sdk/client-s3" />

Run the following Node.js script with `node index.js`. Ensure you change `Bucket` to the name of your bucket, and `Key` to point to an existing file in your R2 bucket.

	Note, tutorial below should function for TypeScript as well.

```javascript title="index.js"
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client();

const Bucket = "<YOUR_BUCKET_NAME>";
const Key = "pfp.jpg";

const object = await s3.send(
  new GetObjectCommand({
    Bucket,
    Key,
  }),
);

console.log("Successfully fetched the object", object.$metadata);

// Process the data as needed
// For example, to get the content as a Buffer:
// const content = data.Body;

// Or to save the file (requires 'fs' module):
// import { writeFile } from "node:fs/promises";
// await writeFile('ingested_0001.parquet', data.Body);
```
Install the `boto3` S3 API client:

```sh
pip install boto3
```

Run the following Python script with `python3 get_r2_object.py`. Ensure you change `bucket` to the name of your bucket, and `object_key` to point to an existing file in your R2 bucket.

```python title="get_r2_object.py"
import boto3
from botocore.client import Config

# Configure the S3 client for Cloudflare R2
s3_client = boto3.client('s3',
  config=Config(signature_version='s3v4')
)

# Specify the object key
#
bucket = '<YOUR_BUCKET_NAME>'
object_key = '2024/08/02/ingested_0001.parquet'

try:
  # Fetch the object
  response = s3_client.get_object(Bucket=bucket, Key=object_key)

  print('Successfully fetched the object')

  # Process the response content as needed
  # For example, to read the content:
  # object_content = response['Body'].read()

  # Or to save the file:
  # with open('ingested_0001.parquet', 'wb') as f:
  #     f.write(response['Body'].read())

except Exception as e:
  print(f'Failed to fetch the object. Error: {str(e)}')
</TabItem>
<TabItem label="Go" icon="seti:go">

  Use `go get` to add the `aws-sdk-go-v2` packages to your Go project:

  ```sh
  go get github.com/aws/aws-sdk-go-v2
  go get github.com/aws/aws-sdk-go-v2/config
  go get github.com/aws/aws-sdk-go-v2/credentials
  go get github.com/aws/aws-sdk-go-v2/service/s3
  ```

  Run the following Go application as a script with `go run main.go`. Ensure you change `bucket` to the name of your bucket, and `objectKey` to point to an existing file in your R2 bucket.

  ```go
 	package main

  import (
	  "context"
	  "fmt"
	  "io"
	  "log"
	  "github.com/aws/aws-sdk-go-v2/aws"
	  "github.com/aws/aws-sdk-go-v2/config"
	  "github.com/aws/aws-sdk-go-v2/service/s3"
  )

  func main() {
      cfg, err := config.LoadDefaultConfig(context.TODO())
      if err != nil {
  	    log.Fatalf("Unable to load SDK config, %v", err)
      }

      // Create an S3 client
      client := s3.NewFromConfig(cfg)

      // Specify the object key
      bucket := "<YOUR_BUCKET_NAME>"
      objectKey := "pfp.jpg"

      // Fetch the object
      output, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
  	    Bucket: aws.String(bucket),
  	    Key:    aws.String(objectKey),
      })
      if err != nil {
  	    log.Fatalf("Unable to fetch object, %v", err)
      }
      defer output.Body.Close()

      fmt.Println("Successfully fetched the object")

      // Process the object content as needed
      // For example, to save the file:
      // file, err := os.Create("ingested_0001.parquet")
      // if err != nil {
      // 	log.Fatalf("Unable to create file, %v", err)
      // }
      // defer file.Close()
      // _, err = io.Copy(file, output.Body)
      // if err != nil {
      // 	log.Fatalf("Unable to write file, %v", err)
      // }

      // Or to read the content:
      content, err := io.ReadAll(output.Body)
      if err != nil {
  	    log.Fatalf("Unable to read object content, %v", err)
      }
      fmt.Printf("Object content length: %d bytes\n", len(content))
  }
  ```
</TabItem>
</Tabs>

Authenticate against R2 with temporary credentials

import { PackageManagers, Tabs, TabItem } from "~/components";

The following examples show how to generate R2 temporary credentials via both the Temporary Credentials API and local client-side signing, and how to use the resulting credentials with an S3 client.

Prerequisites

Generate via the Temporary Credentials API

Call the Temporary Credentials API from a trusted server, then use the returned credentials with any S3 client.

curl https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/r2/temp-access-credentials \
  --header "Authorization: Bearer <PARENT_API_TOKEN>" \
  --header "Content-Type: application/json" \
  --data '{
    "bucket": "my-bucket",
    "parentAccessKeyId": "<PARENT_ACCESS_KEY_ID>",
    "permission": "object-read-only",
    "ttlSeconds": 900,
    "objects": ["reports/2026-q1.pdf"]
  }'

The response wraps the credentials in a result object:

{
  "result": {
    "accessKeyId": "<accessKeyId>",
    "secretAccessKey": "<secretAccessKey>",
    "sessionToken": "<sessionToken>"
  },
  "errors": [],
  "messages": [],
  "success": true
}

Generate locally (client-side signing)

This example uses jose to sign the JWT and aws4fetch to issue signed requests.

The following helper signs a JWT with your parent secret access key and derives the temporary secret access key and session token:

import { SignJWT } from "jose";

type R2Scope =
  | "object-read-only"
  | "object-read-write"
  | "admin-read-only"
  | "admin-read-write";

// Permitted operations: set exactly one of `scope` or `actions`, never both.
type PermittedOperations =
  | { scope: R2Scope; actions?: never }
  | { actions: [string, ...string[]]; scope?: never };

export type TempCredentialOptions = PermittedOperations & {
  // Time-to-live in seconds. Defaults to 1 hour.
  ttlSeconds?: number;
  // Optional: restrict access to specific prefixes or objects.
  paths?: { prefixPaths?: string[]; objectPaths?: string[] };
};

export async function createTempCredentials(
  endpoint: string,
  accountId: string,
  parentAccessKeyId: string,
  parentSecretAccessKey: string,
  bucket: string,
  opts: TempCredentialOptions,
): Promise<{
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string;
}> {
  const ttl = opts.ttlSeconds ?? 3600;

  const claims: Record<string, unknown> = { bucket };

  if (opts.actions !== undefined) {
    claims.actions = opts.actions;
  } else {
    claims.scope = opts.scope;
  }

  if (opts.paths !== undefined) {
    claims.paths = {
      prefixPaths: opts.paths.prefixPaths ?? [],
      objectPaths: opts.paths.objectPaths ?? [],
    };
  }

  // Sign the JWT with the parent secret access key. R2 validates this signature.
  const jwt = await new SignJWT(claims)
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setSubject(accountId)
    .setIssuer(parentAccessKeyId)
    .setAudience(new URL(endpoint).host)
    .setIssuedAt()
    .setExpirationTime(`${ttl}s`)
    .sign(new TextEncoder().encode(parentSecretAccessKey));

  // The temporary secret access key is the SHA-256 hex digest of the signed JWT.
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(jwt),
  );
  const secretAccessKey = Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return {
    // Reuse the parent access key ID as the temporary access key ID.
    accessKeyId: parentAccessKeyId,
    secretAccessKey,
    // The session token is base64("jwt/" + signed JWT).
    sessionToken: btoa(`jwt/${jwt}`),
  };
}

The following example returns a credential that is valid for 15 minutes and can only GetObject and HeadObject under the data/ prefix:

import { createTempCredentials } from "./temp-credentials";

const R2_URL = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;

const creds = await createTempCredentials(
  R2_URL,
  ACCOUNT_ID,
  PARENT_ACCESS_KEY_ID,
  PARENT_SECRET_ACCESS_KEY,
  "my-bucket",
  {
    actions: ["GetObject", "HeadObject"],
    ttlSeconds: 900,
    paths: { prefixPaths: ["data/"] },
  },
);

Use the credentials

Once you have a temporary credential, usage is the same regardless of how it was generated. Pass the three values to your S3 client and issue requests. The following example uses credentials scoped to the data/ prefix to demonstrate one permitted and one rejected request:

import { AwsClient } from "aws4fetch";

const R2_URL = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;

const client = new AwsClient({
  accessKeyId: ACCESS_KEY_ID,
  secretAccessKey: SECRET_ACCESS_KEY,
  sessionToken: SESSION_TOKEN,
  service: "s3",
});

// Allowed: object under the data/ prefix.
const ok = await client.fetch(`${R2_URL}/my-bucket/data/file.bin`);
console.log(ok.status); // 200

// Rejected with 403 AccessDenied because the object is outside the data/ prefix.
const denied = await client.fetch(`${R2_URL}/my-bucket/other/file.bin`);
console.log(denied.status); // 403

Use the Cache API

Use the Cache API to store R2 objects in Cloudflare's cache.

:::note

You will need to connect a custom domain or route to your Worker in order to use the Cache API. Cache API operations in the Cloudflare Workers dashboard editor, Playground previews, and any *.workers.dev deployments will have no impact.

:::


export default {
  async fetch(request, env, context) {
    try {
      const url = new URL(request.url);

      // Construct the cache key from the cache URL
      const cacheKey = new Request(url.toString(), request);
      const cache = caches.default;

      // Check whether the value is already available in the cache
      // if not, you will need to fetch it from R2, and store it in the cache
      // for future access
      let response = await cache.match(cacheKey);

      if (response) {
        console.log(`Cache hit for: ${request.url}.`);
        return response;
      }

      console.log(
        `Response for request url: ${request.url} not present in cache. Fetching and caching request.`
      );

      // If not in cache, get it from R2
      const objectKey = url.pathname.slice(1);
      const object = await env.MY_BUCKET.get(objectKey);
      if (object === null) {
        return new Response('Object Not Found', { status: 404 });
      }

      // Set the appropriate object headers
      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set('etag', object.httpEtag);

      // Cache API respects Cache-Control headers. Setting s-max-age to 10
      // will limit the response to be in cache for 10 seconds max
      // Any changes made to the response here will be reflected in the cached value
      headers.append('Cache-Control', 's-maxage=10');

      response = new Response(object.body, {
        headers,
      });

      // Store the fetched response as cacheKey
      // Use waitUntil so you can return the response without blocking on
      // writing to cache
      context.waitUntil(cache.put(cacheKey, response.clone()));

      return response;
    } catch (e) {
      return new Response('Error thrown ' + e.message);
    }
  },
};

Examples

import { DirectoryListing, GlossaryTooltip } from "~/components";

Explore the following examples of how to use SDKs and other tools with R2.

Rclone

import { Render, Steps } from "~/components";


Rclone is a command-line tool which manages files on cloud storage. You can use rclone to upload objects to R2 concurrently.

Configure rclone

With rclone installed, you may run rclone config to configure a new S3 storage provider. You will be prompted with a series of questions for the new provider details.

:::note[Recommendation] It is recommended that you choose a unique provider name and then rely on all default answers to the prompts.

This will create a rclone configuration file, which you can then modify with the preset configuration given below. :::

1. Create new remote by selecting `n`. 2. Select a name for the new remote. For example, use `r2`. 3. Select the `Amazon S3 Compliant Storage Providers` storage type. 4. Select `Cloudflare R2 storage` for the provider. 5. Select whether you would like to enter AWS credentials manually, or get it from the runtime environment. 6. Enter the AWS Access Key ID. 7. Enter AWS Secret Access Key (password). 8. Select the region to connect to (optional). 9. Select the S3 API endpoint.

:::note Ensure you are running rclone v1.59 or greater (rclone downloads). Versions prior to v1.59 may return HTTP 401: Unauthorized errors, as earlier versions of rclone do not strictly align to the S3 specification in all cases. :::

Edit an existing rclone configuration

If you have already configured rclone in the past, you may run rclone config file to print the location of your rclone configuration file:

rclone config file
# Configuration file is stored at:
# ~/.config/rclone/rclone.conf

Then use an editor (nano or vim, for example) to add or edit the new provider. This example assumes you are adding a new r2 provider:

[r2]
type = s3
provider = Cloudflare
access_key_id = abc123
secret_access_key = xyz456
endpoint = https://<accountid>.r2.cloudflarestorage.com
acl = private

:::note

If you are using a token with Object-level permissions, you will need to add no_check_bucket = true to the configuration to avoid errors. :::

You may then use the new rclone provider for any of your normal workflows.

List buckets & objects

The rclone tree command can be used to list the contents of the remote, in this case Cloudflare R2.

rclone tree r2:
# /
# ├── user-uploads
# │   └── foobar.png
# └── my-bucket-name
#     ├── cat.png
#     └── todos.txt

rclone tree r2:my-bucket-name
# /
# ├── cat.png
# └── todos.txt

Upload and retrieve objects

The rclone copy command can be used to upload objects to an R2 bucket and vice versa - this allows you to upload files up to the 5 TB maximum object size that R2 supports.

# Upload dog.txt to the user-uploads bucket
rclone copy dog.txt r2:user-uploads/
rclone tree r2:user-uploads
# /
# ├── foobar.png
# └── dog.txt

# Download dog.txt from the user-uploads bucket
rclone copy r2:user-uploads/dog.txt .

A note about multipart upload part sizes

For multipart uploads, part sizes can significantly affect the number of Class A operations that are used, which can alter how much you end up being charged. Every part upload counts as a separate operation, so larger part sizes will use fewer operations, but might be costly to retry if the upload fails. Also consider that a multipart upload is always going to consume at least 3 times as many operations as a single PutObject, because it will include at least one CreateMultipartUpload, UploadPart & CompleteMultipartUpload operations.

Balancing part size depends heavily on your use-case, but these factors can help you minimize your bill, so they are worth thinking about.

You can configure rclone's multipart upload part size using the --s3-chunk-size CLI argument. Note that you might also have to adjust the --s3-upload-cutoff argument to ensure that rclone is using multipart uploads. Both of these can be set in your configuration file as well. Generally, --s3-upload-cutoff will be no less than --s3-chunk-size.

rclone copy long-video.mp4 r2:user-uploads/ --s3-upload-cutoff=100M --s3-chunk-size=100M

Generate presigned URLs

You can also generate presigned links which allow you to share public access to a file temporarily using the rclone link command.

# You can pass the --expire flag to determine how long the presigned link is valid. The --unlink flag isn't supported by R2.
rclone link r2:my-bucket-name/cat.png --expire 3600
# https://<accountid>.r2.cloudflarestorage.com/my-bucket-name/cat.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>

Use SSE-C

import { Tabs, TabItem } from "~/components";

The following tutorial shows some snippets for how to use Server-Side Encryption with Customer-Provided Keys (SSE-C) on R2.

Before you begin

Workers

```typescript interface Environment { R2: R2Bucket /** * In this example, your SSE-C is stored as a hexadecimal string (preferably a secret). * The R2 API also supports providing an ArrayBuffer directly, if you want to generate/ * store your keys dynamically. */ SSEC_KEY: string } export default { async fetch(req: Request, env: Env) { const { SSEC_KEY, R2 } = env; const { pathname: filename } = new URL(req.url); switch(req.method) { case "GET": { const maybeObj = await env.BUCKET.get(filename, { onlyIf: req.headers, ssecKey: SSEC_KEY, }); if(!maybeObj) { return new Response("Not Found", { status: 404 }); } const headers = new Headers(); maybeObj.writeHttpMetadata(headers); return new Response(body, { headers }); } case 'POST': { const multipartUpload = await env.BUCKET.createMultipartUpload(filename, { httpMetadata: req.headers, ssecKey: SSEC_KEY, }); /** * This example only provides a single-part "multipart" upload. * For multiple parts, the process is the same(the key must be provided) * for every part. */ const partOne = await multipartUpload.uploadPart(1, req.body, ssecKey); const obj = await multipartUpload.complete([partOne]); const headers = new Headers(); obj.writeHttpMetadata(headers); return new Response(null, { headers, status: 201 }); } case 'PUT': { const obj = await env.BUCKET.put(filename, req.body, { httpMetadata: req.headers, ssecKey: SSEC_KEY, }); const headers = new Headers(); maybeObj.writeHttpMetadata(headers); return new Response(null, { headers, status: 201 }); } default: { return new Response("Method not allowed", { status: 405 }); } } } } ``` ```javascript /** * In this example, your SSE-C is stored as a hexadecimal string(preferably a secret). * The R2 API also supports providing an ArrayBuffer directly, if you want to generate/ * store your keys dynamically. */ export default { async fetch(req, env) { const { SSEC_KEY, R2 } = env; const { pathname: filename } = new URL(req.url); switch(req.method) { case "GET": { const maybeObj = await env.BUCKET.get(filename, { onlyIf: req.headers, ssecKey: SSEC_KEY, }); if(!maybeObj) { return new Response("Not Found", { status: 404 }); } const headers = new Headers(); maybeObj.writeHttpMetadata(headers); return new Response(body, { headers }); } case 'POST': { const multipartUpload = await env.BUCKET.createMultipartUpload(filename, { httpMetadata: req.headers, ssecKey: SSEC_KEY, }); /** * This example only provides a single-part "multipart" upload. * For multiple parts, the process is the same(the key must be provided) * for every part. */ const partOne = await multipartUpload.uploadPart(1, req.body, ssecKey); const obj = await multipartUpload.complete([partOne]); const headers = new Headers(); obj.writeHttpMetadata(headers); return new Response(null, { headers, status: 201 }); } case 'PUT': { const obj = await env.BUCKET.put(filename, req.body, { httpMetadata: req.headers, ssecKey: SSEC_KEY, }); const headers = new Headers(); maybeObj.writeHttpMetadata(headers); return new Response(null, { headers, status: 201 }); } default: { return new Response("Method not allowed", { status: 405 }); } } } } ```

S3-API

```typescript import { UploadPartCommand, PutObjectCommand, S3Client, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, type UploadPartCommandOutput } from "@aws-sdk/client-s3";
	const s3 = new S3Client({
		endpoint: process.env.R2_ENDPOINT,
		credentials: {
			accessKeyId: process.env.R2_ACCESS_KEY_ID,
			secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
		},
	});

	const SSECustomerAlgorithm = "AES256";
	const SSECustomerKey = process.env.R2_SSEC_KEY;
	const SSECustomerKeyMD5 = process.env.R2_SSEC_KEY_MD5;

	await s3.send(
		new PutObjectCommand({
			Bucket: "your-bucket",
			Key: "single-part",
			Body: "BeepBoop",
			SSECustomerAlgorithm,
			SSECustomerKey,
			SSECustomerKeyMD5,
		}),
	);

	const multi = await s3.send(
		new CreateMultipartUploadCommand({
			Bucket: "your-bucket",
			Key: "multi-part",
			SSECustomerAlgorithm,
			SSECustomerKey,
			SSECustomerKeyMD5,
		}),
	);
	const UploadId = multi.UploadId;

	const parts: UploadPartCommandOutput[] = [];

	parts.push(
		await s3.send(
			new UploadPartCommand({
				Bucket: "your-bucket",
				Key: "multi-part",
				UploadId,
				// 	filledBuf()` generates some random data.
				// Replace with a function/body of your choice.
				Body: filledBuf(),
				PartNumber: 1,
				SSECustomerAlgorithm,
				SSECustomerKey,
				SSECustomerKeyMD5,
			}),
		),
	);
	parts.push(
		await s3.send(
			new UploadPartCommand({
				Bucket: "your-bucket",
				Key: "multi-part",
				UploadId,
				// 	filledBuf()` generates some random data.
				// Replace with a function/body of your choice.
				Body: filledBuf(),
				PartNumber: 2,
				SSECustomerAlgorithm,
				SSECustomerKey,
				SSECustomerKeyMD5,
			}),
		),
	);
	await s3.send(
		new CompleteMultipartUploadCommand({
			Bucket: "your-bucket",
			Key: "multi-part",
			UploadId,
			MultipartUpload: {
				Parts: parts.map(({ ETag }, PartNumber) => ({
					ETag,
					PartNumber: PartNumber + 1,
				})),
			},
			SSECustomerAlgorithm,
			SSECustomerKey,
			SSECustomerKeyMD5,
		}),
	);

	const HeadObjectOutput = await s3.send(
		new HeadObjectCommand({
			Bucket: "your-bucket",
			Key: "multi-part",
			SSECustomerAlgorithm,
			SSECustomerKey,
			SSECustomerKeyMD5,
		}),
	);

	const GetObjectOutput = await s3.send(
		new GetObjectCommand({
			Bucket: "your-bucket",
			Key: "single-part",
			SSECustomerAlgorithm,
			SSECustomerKey,
			SSECustomerKeyMD5,
		}),
	);
```

Terraform (AWS)

import { Render } from "~/components";


This example shows how to configure R2 with Terraform using the AWS provider.

:::note[Note for using AWS provider]

For using only the Cloudflare provider, see Terraform.

:::

With terraform installed:

  1. Create main.tf file, or edit your existing Terraform configuration
  2. Populate the endpoint URL at endpoints.s3 with your Cloudflare account ID
  3. Populate access_key and secret_key with the corresponding R2 API credentials.
  4. Ensure that skip_region_validation = true, skip_requesting_account_id = true, and skip_credentials_validation = true are set in the provider configuration.
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 5"
    }
  }
}

provider "aws" {
  region = "us-east-1"

  access_key = <R2 Access Key>
  secret_key = <R2 Secret Key>

	# Required for R2.
	# These options disable S3-specific validation on the client (Terraform) side.
  skip_credentials_validation = true
  skip_region_validation      = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = "https://<account id>.r2.cloudflarestorage.com"
  }
}

resource "aws_s3_bucket" "default" {
  bucket = "<org>-test"
}

resource "aws_s3_bucket_cors_configuration" "default" {
  bucket   = aws_s3_bucket.default.id

  cors_rule {
    allowed_methods = ["GET"]
    allowed_origins = ["*"]
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "default" {
  bucket = aws_s3_bucket.default.id

  rule {
    id     = "expire-bucket"
    status = "Enabled"
    expiration {
      days = 1
    }
  }

  rule {
    id     = "abort-multipart-upload"
    status = "Enabled"
    abort_incomplete_multipart_upload {
      days_after_initiation = 1
    }
  }
}

You can then use terraform plan to view the changes and terraform apply to apply changes.

Terraform

import { Render } from "~/components"


This example shows how to configure R2 with Terraform using the Cloudflare provider.

:::note[Note for using AWS provider]

When using the Cloudflare Terraform provider, you can only manage buckets. To configure items such as CORS and object lifecycles, you will need to use the AWS Provider.

:::

With terraform installed, create main.tf and copy the content below replacing with your API Token.

terraform {
  required_providers {
    cloudflare = {
      source = "cloudflare/cloudflare"
      version = "~> 4"
    }
  }
}

provider "cloudflare" {
  api_token = "<YOUR_API_TOKEN>"
}

resource "cloudflare_r2_bucket" "cloudflare-bucket" {
  account_id = "<YOUR_ACCOUNT_ID>"
  name       = "my-tf-test-bucket"
  location   = "WEUR"
}

You can then use terraform plan to view the changes and terraform apply to apply changes.

aws CLI

import { Render } from "~/components";


With the aws CLI installed, you may run aws configure to configure a new profile. You will be prompted with a series of questions for the new profile's details.

aws configure
AWS Access Key ID [None]: <ACCESS_KEY_ID>
AWS Secret Access Key [None]: <SECRET_ACCESS_KEY>
Default region name [None]: auto
Default output format [None]: json

The region value can be set to auto since it is required by the SDK but not used by R2.

You may then use the aws CLI for any of your normal workflows.

# Provide your Cloudflare account ID
aws s3api list-buckets --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com
# {
#     "Buckets": [
#         {
#             "Name": "my-bucket",
#             "CreationDate": "2022-05-18T17:19:59.645000+00:00"
#         }
#     ],
#     "Owner": {
#         "DisplayName": "134a5a2c0ba47b38eada4b9c8ead10b6",
#         "ID": "134a5a2c0ba47b38eada4b9c8ead10b6"
#     }
# }

aws s3api list-objects-v2 --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com --bucket my-bucket
# {
#     "Contents": [
#         {
#             "Key": "ferriswasm.png",
#             "LastModified": "2022-05-18T17:20:21.670000+00:00",
#             "ETag": "\"eb2b891dc67b81755d2b726d9110af16\"",
#             "Size": 87671,
#             "StorageClass": "STANDARD"
#         }
#     ]
# }

Generate presigned URLs

You can also generate presigned links which allow you to share public access to a file temporarily.

# You can pass the --expires-in flag to determine how long the presigned link is valid.
aws s3 presign --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com  s3://my-bucket/ferriswasm.png --expires-in 3600
# https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/ferriswasm.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>

aws-sdk-go

import { Render } from "~/components";


This example uses version 2 of the aws-sdk-go package. You must pass in the R2 configuration credentials when instantiating your S3 service client:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"log"
)

func main() {
	var bucketName = "sdk-example"
	// Provide your Cloudflare account ID
	var accountId = "<ACCOUNT_ID>"
	// Retrieve your S3 API credentials for your R2 bucket via API tokens
	// (see: https://developers.cloudflare.com/r2/api/tokens)
	var accessKeyId = "<ACCESS_KEY_ID>"
	var accessKeySecret = "<SECRET_ACCESS_KEY>"

	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, "")),
		config.WithRegion("auto"), // Required by SDK but not used by R2
	)
	if err != nil {
		log.Fatal(err)
	}

	client := s3.NewFromConfig(cfg, func(o *s3.Options) {
			o.BaseEndpoint = aws.String(fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountId))
	})

	listObjectsOutput, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
		Bucket: &bucketName,
	})
	if err != nil {
		log.Fatal(err)
	}

	for _, object := range listObjectsOutput.Contents {
		obj, _ := json.MarshalIndent(object, "", "\t")
		fmt.Println(string(obj))
	}

	//  {
	//  	"ChecksumAlgorithm": null,
	//  	"ETag": "\"eb2b891dc67b81755d2b726d9110af16\"",
	//  	"Key": "ferriswasm.png",
	//  	"LastModified": "2022-05-18T17:20:21.67Z",
	//  	"Owner": null,
	//  	"Size": 87671,
	//  	"StorageClass": "STANDARD"
	//  }

	listBucketsOutput, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
	if err != nil {
		log.Fatal(err)
	}

	for _, object := range listBucketsOutput.Buckets {
		obj, _ := json.MarshalIndent(object, "", "\t")
		fmt.Println(string(obj))
	}

	// {
	// 		"CreationDate": "2022-05-18T17:19:59.645Z",
	// 		"Name": "sdk-example"
	// }
}

Generate presigned URLs

You can also generate presigned links that can be used to temporarily share public write access to a bucket.

presignClient := s3.NewPresignClient(client)

	presignResult, err := presignClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{
		Bucket: aws.String(bucketName),
		Key:    aws.String("example.txt"),
	})

	if err != nil {
		panic("Couldn't get presigned URL for PutObject")
	}

	fmt.Printf("Presigned URL For object: %s\n", presignResult.URL)

aws-sdk-java

import { Render } from "~/components";


This example uses version 2 of the aws-sdk-java package. You must pass in the R2 configuration credentials when instantiating your S3 service client.

:::note

You must set chunkedEncodingEnabled(false) in the S3Configuration when building your client. The AWS SDK for Java v2 uses chunked transfer encoding by default for putObject requests, which causes a signature mismatch error (HTTP 403) with R2. Disabling chunked encoding ensures the request signature is calculated correctly. :::

import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.core.sync.RequestBody;
import java.net.URI;
import java.util.List;

/**
 * Client for interacting with Cloudflare R2 Storage using AWS SDK S3 compatibility
 */
public class CloudflareR2Client {
    private final S3Client s3Client;

    /**
     * Creates a new CloudflareR2Client with the provided configuration
     */
    public CloudflareR2Client(S3Config config) {
        this.s3Client = buildS3Client(config);
    }

    /**
     * Configuration class for R2 credentials and endpoint
     * - accountId: Your Cloudflare account ID
     * - accessKey: Your R2 Access Key ID (see: https://developers.cloudflare.com/r2/api/tokens)
     * - secretKey: Your R2 Secret Access Key (see: https://developers.cloudflare.com/r2/api/tokens)
     */
    public static class S3Config {
        private final String accountId;
        private final String accessKey;
        private final String secretKey;
        private final String endpoint;

        public S3Config(String accountId, String accessKey, String secretKey) {
            this.accountId = accountId;
            this.accessKey = accessKey;
            this.secretKey = secretKey;
            this.endpoint = String.format("https://%s.r2.cloudflarestorage.com", accountId);
        }

        public String getAccessKey() { return accessKey; }
        public String getSecretKey() { return secretKey; }
        public String getEndpoint() { return endpoint; }
    }

    /**
     * Builds and configures the S3 client with R2-specific settings
     */
    private static S3Client buildS3Client(S3Config config) {
        AwsBasicCredentials credentials = AwsBasicCredentials.create(
            config.getAccessKey(),
            config.getSecretKey()
        );

        S3Configuration serviceConfiguration = S3Configuration.builder()
            .pathStyleAccessEnabled(true)
            .chunkedEncodingEnabled(false)
            .build();

        return S3Client.builder()
            .endpointOverride(URI.create(config.getEndpoint()))
            .credentialsProvider(StaticCredentialsProvider.create(credentials))
            .region(Region.of("auto")) // Required by SDK but not used by R2
            .serviceConfiguration(serviceConfiguration)
            .build();
    }

    /**
     * Lists all buckets in the R2 storage
     */
    public List<Bucket> listBuckets() {
        try {
            return s3Client.listBuckets().buckets();
        } catch (S3Exception e) {
            throw new RuntimeException("Failed to list buckets: " + e.getMessage(), e);
        }
    }

    /**
     * Lists all objects in the specified bucket
     */
    public List<S3Object> listObjects(String bucketName) {
        try {
            ListObjectsV2Request request = ListObjectsV2Request.builder()
                .bucket(bucketName)
                .build();

            return s3Client.listObjectsV2(request).contents();
        } catch (S3Exception e) {
            throw new RuntimeException("Failed to list objects in bucket " + bucketName + ": " + e.getMessage(), e);
        }
    }

    /**
     * Uploads an object to the specified bucket
     */
    public void putObject(String bucketName, String key, String content) {
        try {
            PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .build();

            s3Client.putObject(request, RequestBody.fromString(content));
        } catch (S3Exception e) {
            throw new RuntimeException("Failed to put object " + key + " in bucket " + bucketName + ": " + e.getMessage(), e);
        }
    }

    public static void main(String[] args) {
        S3Config config = new S3Config(
            "<ACCOUNT_ID>",
            "<ACCESS_KEY_ID>",
            "<SECRET_ACCESS_KEY>"
        );

        CloudflareR2Client r2Client = new CloudflareR2Client(config);

        // List buckets
        System.out.println("Available buckets:");
        r2Client.listBuckets().forEach(bucket ->
            System.out.println("* " + bucket.name())
        );

        // Upload an object to a bucket
        String bucketName = "demos";
        r2Client.putObject(bucketName, "example.txt", "Hello, R2!");
        System.out.println("Uploaded example.txt to bucket '" + bucketName + "'");

        // List objects in a specific bucket
        System.out.println("\nObjects in bucket '" + bucketName + "':");
        r2Client.listObjects(bucketName).forEach(object ->
            System.out.printf("* %s (size: %d bytes, modified: %s)%n",
                object.key(),
                object.size(),
                object.lastModified())
        );
    }
}

Generate presigned URLs

You can also generate presigned links that can be used to temporarily share public write access to a bucket.

// import required packages for presigning
// Rest of the packages are same as above
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import java.time.Duration;

public class CloudflareR2Client {
  private final S3Client s3Client;
  private final S3Presigner presigner;

    /**
     * Creates a new CloudflareR2Client with the provided configuration
     */
    public CloudflareR2Client(S3Config config) {
        this.s3Client = buildS3Client(config);
        this.presigner = buildS3Presigner(config);
    }

    /**
     * Builds and configures the S3 presigner with R2-specific settings
     */
    private static S3Presigner buildS3Presigner(S3Config config) {
        AwsBasicCredentials credentials = AwsBasicCredentials.create(
            config.getAccessKey(),
            config.getSecretKey()
        );

        return S3Presigner.builder()
            .endpointOverride(URI.create(config.getEndpoint()))
            .credentialsProvider(StaticCredentialsProvider.create(credentials))
            .region(Region.of("auto")) // Required by SDK but not used by R2
            .serviceConfiguration(S3Configuration.builder()
                .pathStyleAccessEnabled(true)
                .build())
            .build();
    }

    public String generatePresignedUploadUrl(String bucketName, String objectKey, Duration expiration) {
        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
            .signatureDuration(expiration)
            .putObjectRequest(builder -> builder
                .bucket(bucketName)
                .key(objectKey)
                .build())
            .build();

        PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest);
        return presignedRequest.url().toString();
    }

    // Rest of the methods remains the same

    public static void main(String[] args) {
      // config the client as before

      // Generate a pre-signed upload URL valid for 15 minutes
        String uploadUrl = r2Client.generatePresignedUploadUrl(
            "demos",
            "README.md",
            Duration.ofMinutes(15)
        );
        System.out.println("Pre-signed Upload URL (valid for 15 minutes):");
        System.out.println(uploadUrl);
    }

}

aws-sdk-js-v3

import { Render, WranglerConfig } from "~/components";


JavaScript or TypeScript users may continue to use the @aws-sdk/client-s3 npm package as per normal. You must pass in the R2 configuration credentials when instantiating your S3 service client.

:::note[Using the SDK in a Worker] The AWS SDK for JavaScript v3 works in Workers with the nodejs_compat compatibility flag enabled. Operations that parse an XML response, such as ListObjectsV2, CopyObject, DeleteObjects, and multipart uploads, can fail with ReferenceError: DOMParser is not defined when Wrangler bundles the SDK. This happens because the browser field of @aws-sdk/xml-builder swaps in a parser that depends on DOMParser, which the Workers runtime does not provide. To work around this, install @aws-sdk/xml-builder as a direct dependency (for example, npm i @aws-sdk/xml-builder) so that it resolves from your project root, then alias the package to its CommonJS build in your Wrangler configuration file:

compatibility_date = "$today"
compatibility_flags = ["nodejs_compat"]

[alias]
"@aws-sdk/xml-builder" = "@aws-sdk/xml-builder/dist-cjs/index.js"

If you only need object reads and writes from a Worker, the R2 Workers API binding does not require the SDK. aws4fetch is a lighter alternative for S3-compatible requests and presigned URLs. :::

:::note Currently, you cannot use AWS S3-compatible API while developing locally via wrangler dev. :::

import {
	S3Client,
	ListBucketsCommand,
	ListObjectsV2Command,
	GetObjectCommand,
	PutObjectCommand,
} from "@aws-sdk/client-s3";

const S3 = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	// Provide your Cloudflare account ID
	endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: ACCESS_KEY_ID,
		secretAccessKey: SECRET_ACCESS_KEY,
	},
});

console.log(await S3.send(new ListBucketsCommand({})));
// {
//     '$metadata': {
//     httpStatusCode: 200,
//         requestId: undefined,
//         extendedRequestId: undefined,
//         cfId: undefined,
//         attempts: 1,
//         totalRetryDelay: 0
// },
//     Buckets: [
//     { Name: 'user-uploads', CreationDate: 2022-04-13T21:23:47.102Z },
//     { Name: 'my-bucket', CreationDate: 2022-05-07T02:46:49.218Z }
//     ],
//     Owner: {
//         DisplayName: '...',
//         ID: '...'
//     }
// }

console.log(await S3.send(new ListObjectsV2Command({ Bucket: "my-bucket" })));
// {
//     '$metadata': {
//       httpStatusCode: 200,
//       requestId: undefined,
//       extendedRequestId: undefined,
//       cfId: undefined,
//       attempts: 1,
//       totalRetryDelay: 0
//     },
//     CommonPrefixes: undefined,
//     Contents: [
//       {
//         Key: 'cat.png',
//         LastModified: 2022-05-07T02:50:45.616Z,
//         ETag: '"c4da329b38467509049e615c11b0c48a"',
//         ChecksumAlgorithm: undefined,
//         Size: 751832,
//         StorageClass: 'STANDARD',
//         Owner: undefined
//       },
//       {
//         Key: 'todos.txt',
//         LastModified: 2022-05-07T21:37:17.150Z,
//         ETag: '"29d911f495d1ba7cb3a4d7d15e63236a"',
//         ChecksumAlgorithm: undefined,
//         Size: 279,
//         StorageClass: 'STANDARD',
//         Owner: undefined
//       }
//     ],
//     ContinuationToken: undefined,
//     Delimiter: undefined,
//     EncodingType: undefined,
//     IsTruncated: false,
//     KeyCount: 8,
//     MaxKeys: 1000,
//     Name: 'my-bucket',
//     NextContinuationToken: undefined,
//     Prefix: undefined,
//     StartAfter: undefined
//   }

Generate presigned URLs

You can also generate presigned links that can be used to share public read or write access to a bucket temporarily.

import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// Use the expiresIn property to determine how long the presigned link is valid.
console.log(
	await getSignedUrl(
		S3,
		new GetObjectCommand({ Bucket: "my-bucket", Key: "dog.png" }),
		{ expiresIn: 3600 },
	),
);
// You can also create links for operations such as PutObject to allow temporary write access to a specific key.
// Specify ContentType to restrict uploads to a specific file type.
console.log(
	await getSignedUrl(
		S3,
		new PutObjectCommand({
			Bucket: "my-bucket",
			Key: "dog.png",
			ContentType: "image/png",
		}),
		{ expiresIn: 3600 },
	),
);
https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/dog.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>&x-id=GetObject
https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/dog.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=<signature>&x-id=PutObject
curl -X PUT "https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/dog.png?X-Amz-Algorithm=..." \
  -H "Content-Type: image/png" \
  --data-binary @dog.png

Restrict uploads with CORS and Content-Type

When generating presigned URLs for uploads, you can limit abuse and misuse by:

  1. Restricting Content-Type: Specify the allowed content type in the PutObjectCommand. The upload will fail if the client sends a different Content-Type header.
  2. Configuring CORS: Set up CORS rules on your bucket to control which origins can upload files. Configure CORS via the Cloudflare dashboard by adding a JSON policy to your bucket settings:
[
	{
		"AllowedOrigins": ["https://example.com"],
		"AllowedMethods": ["PUT"],
		"AllowedHeaders": ["Content-Type"],
		"ExposeHeaders": ["ETag"],
		"MaxAgeSeconds": 3600
	}
]

Then generate a presigned URL with a Content-Type restriction:

const putUrl = await getSignedUrl(
	S3,
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "dog.png",
		ContentType: "image/png",
	}),
	{ expiresIn: 3600 },
);

When a client uses this presigned URL, they must:

aws-sdk-kotlin

import { Render } from "~/components";


This example uses the aws-sdk-kotlin. You must pass in the R2 configuration credentials when instantiating your S3 client:

Basic Usage

import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
import aws.sdk.kotlin.services.s3.S3Client
import aws.sdk.kotlin.services.s3.listObjects
import aws.sdk.kotlin.services.s3.model.GetObjectRequest
import aws.sdk.kotlin.services.s3.model.PutObjectRequest
import aws.sdk.kotlin.services.s3.presigners.presignGetObject
import aws.sdk.kotlin.services.s3.presigners.presignPutObject
import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials
import aws.smithy.kotlin.runtime.net.url.Url
import kotlinx.coroutines.runBlocking
import kotlin.time.Duration.Companion.minutes

val ACCOUNT_ID = "<ACCOUNT_ID>"
val ACCESS_KEY = "<ACCESS_KEY>"
val SECRET_KEY = "<SECRET_KEY>"

fun main() = runBlocking {
    val r2Client = S3Client.fromEnvironment {
        region = "auto" // Required by SDK, but not used by R2
        endpointUrl = Url.parse("https://${ACCOUNT_ID}.r2.cloudflarestorage.com")
        credentialsProvider = StaticCredentialsProvider(
            Credentials(
                accessKeyId = ACCESS_KEY,
                secretAccessKey = SECRET_KEY
            ),
        )
    }

    println("Available buckets:")
    r2Client.listBuckets().buckets?.forEach { bucket ->
        println("* ${bucket.name}")
    }

    val bucketName = "<BUCKET_NAME>"
    println("\nObjects in bucket '${bucketName}':")
    r2Client.listObjects { bucket = bucketName }.contents?.forEach {
        println("* ${it.key} (size: ${it.size} bytes, modified: ${it.lastModified})")
    }

    r2Client.close()
}
Available buckets:
* my-bucket-1
* my-bucket-2

Objects in bucket 'my-bucket-1':
* image1.png (size: 253167 bytes, modified: 2026-01-17T11:30:58.896Z)
* image2.png (size: 247027 bytes, modified: 2026-01-17T11:30:57.779Z)

Generate presigned URLs

You can also generate presigned links that can be used to temporarily share public read or write access to a bucket.

val uploadUrl = r2Client.presignPutObject(
    input = PutObjectRequest {
        bucket = bucketName
        key = "README.md"
    },
    duration = 15.minutes,
).url
println(uploadUrl)

val getUrl = r2Client.presignGetObject(
    input = GetObjectRequest {
        bucket = bucketName
        key = "README.md"
    },
    duration = 15.minutes,
).url
println(getUrl)

You can use these presigned URLs with any HTTP client. For example, to upload a file using the PUT URL:

curl -X PUT "https://<your-presigned-put-url>" -H "Content-Type: application/octet-stream" --data-binary "@local-file.txt"

To download a file using the GET URL:

curl -X GET "https://<your-presigned-get-url>" -o downloaded-file.txt

aws-sdk-net

import { Render } from "~/components";


This example uses version 3 of the aws-sdk-net package. You must pass in the R2 configuration credentials when instantiating your S3 service client:

Client setup

In this example, you will pass credentials explicitly to the IAmazonS3 initialization. If you wish, use a shared AWS credentials file or the SDK store in-line with other AWS SDKs. Refer to Configure AWS credentials for more details.

private static IAmazonS3 s3Client;

public static void Main(string[] args)
{
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	var accessKey = "<ACCESS_KEY_ID>";
	var secretKey = "<SECRET_ACCESS_KEY>";
	var credentials = new BasicAWSCredentials(accessKey, secretKey);
	s3Client = new AmazonS3Client(credentials, new AmazonS3Config
		{
			// Provide your Cloudflare account ID
			ServiceURL = "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
		});
}

List buckets and objects

The ListBucketsAsync and ListObjectsAsync methods can be used to list buckets under your account and the contents of those buckets respectively.

static async Task ListBuckets()
{
	var response = await s3Client.ListBucketsAsync();

	foreach (var s3Bucket in response.Buckets)
	{
		Console.WriteLine("{0}", s3Bucket.BucketName);
	}
}
sdk-example
my-bucket
static async Task ListObjectsV2()
{
	var request = new ListObjectsV2Request
	{
		BucketName = "my-bucket"
	};

	var response = await s3Client.ListObjectsV2Async(request);

	foreach (var s3Object in response.S3Objects)
	{
		Console.WriteLine("{0}", s3Object.Key);
	}
}
dog.png
cat.png

Upload and retrieve objects

The PutObjectAsync and GetObjectAsync methods can be used to upload objects and download objects from an R2 bucket respectively.

:::caution DisablePayloadSigning = true and DisableDefaultChecksumValidation = true must be passed as Cloudflare R2 does not currently support the Streaming SigV4 implementation used by AWSSDK.S3. :::

static async Task PutObject()
{
	var request = new PutObjectRequest
	{
		FilePath = @"/path/file.txt",
		BucketName = "my-bucket",
		DisablePayloadSigning = true,
		DisableDefaultChecksumValidation = true
	};

	var response = await s3Client.PutObjectAsync(request);

	Console.WriteLine("ETag: {0}", response.ETag);
}
ETag: "186a71ee365d9686c3b98b6976e1f196"
static async Task GetObject()
{
	var bucket = "my-bucket";
	var key = "file.txt";

	var response = await s3Client.GetObjectAsync(bucket, key);

	Console.WriteLine("ETag: {0}", response.ETag);
}
ETag: "186a71ee365d9686c3b98b6976e1f196"

Generate presigned URLs

The GetPreSignedURL method allows you to sign ahead of time, giving temporary access to a specific operation. In this case, presigning a PutObject request for sdk-example/file.txt.

static string? GeneratePresignedUrl()
{
	AWSConfigsS3.UseSignatureVersion4 = true;
	var presign = new GetPreSignedUrlRequest
	{
		BucketName = "my-bucket",
		Key = "file.txt",
		Verb = HttpVerb.GET,
		Expires = DateTime.Now.AddDays(7),
	};

	var presignedUrl = s3Client.GetPreSignedURL(presign);

	Console.WriteLine(presignedUrl);

	return presignedUrl;
}
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>

aws-sdk-php

import { Render } from "~/components";


This example uses version 3 of the aws-sdk-php package. You must pass in the R2 configuration credentials when instantiating your S3 service client:

<?php
require 'vendor/aws/aws-autoloader.php';

$bucket_name = "my-bucket";
// Provide your Cloudflare account ID
$account_id = "<ACCOUNT_ID>";
// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
$access_key_id = "<ACCESS_KEY_ID>";
$access_key_secret = "<SECRET_ACCESS_KEY>";

$credentials = new Aws\Credentials\Credentials($access_key_id, $access_key_secret);

$options = [
    'region' => 'auto', // Required by SDK but not used by R2
    'endpoint' => "https://$account_id.r2.cloudflarestorage.com",
    'version' => 'latest',
    'credentials' => $credentials
];

$s3_client = new Aws\S3\S3Client($options);

$contents = $s3_client->listObjectsV2([
    'Bucket' => $bucket_name
]);

var_dump($contents['Contents']);

// array(1) {
//   [0]=>
//   array(5) {
//     ["Key"]=>
//     string(14) "ferriswasm.png"
//     ["LastModified"]=>
//     object(Aws\Api\DateTimeResult)#187 (3) {
//       ["date"]=>
//       string(26) "2022-05-18 17:20:21.670000"
//       ["timezone_type"]=>
//       int(2)
//       ["timezone"]=>
//       string(1) "Z"
//     }
//     ["ETag"]=>
//     string(34) ""eb2b891dc67b81755d2b726d9110af16""
//     ["Size"]=>
//     string(5) "87671"
//     ["StorageClass"]=>
//     string(8) "STANDARD"
//   }
// }

$buckets = $s3_client->listBuckets();

var_dump($buckets['Buckets']);

// array(1) {
//   [0]=>
//   array(2) {
//     ["Name"]=>
//     string(11) "my-bucket"
//     ["CreationDate"]=>
//     object(Aws\Api\DateTimeResult)#212 (3) {
//       ["date"]=>
//       string(26) "2022-05-18 17:19:59.645000"
//       ["timezone_type"]=>
//       int(2)
//       ["timezone"]=>
//       string(1) "Z"
//     }
//   }
// }

?>

Generate presigned URLs

You can also generate presigned links that can be used to share public read or write access to a bucket temporarily.

$cmd = $s3_client->getCommand('GetObject', [
    'Bucket' => $bucket_name,
    'Key' => 'ferriswasm.png'
]);

// The second parameter allows you to determine how long the presigned link is valid.
$request = $s3_client->createPresignedRequest($cmd, '+1 hour');

print_r((string)$request->getUri());
// https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/ferriswasm.png?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Signature=<signature>

// You can also create links for operations such as putObject to allow temporary write access to a specific key.
$cmd = $s3_client->getCommand('PutObject', [
    'Bucket' => $bucket_name,
    'Key' => 'ferriswasm.png'
]);

$request = $s3_client->createPresignedRequest($cmd, '+1 hour');

print_r((string)$request->getUri());
curl -X PUT "https://my-bucket.<ACCOUNT_ID>.r2.cloudflarestorage.com/ferriswasm.png?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-Date=<timestamp>&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Signature=<signature>" --data-binary @ferriswasm.png

aws-sdk-ruby

import { Render } from "~/components";


Many Ruby projects also store these credentials in environment variables instead.

Add the following dependency to your Gemfile:

gem "aws-sdk-s3"

Then you can use Ruby to operate on R2 buckets:

require "aws-sdk-s3"

@r2 = Aws::S3::Client.new(
  # Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
  access_key_id: "#{ACCESS_KEY_ID}",
  secret_access_key: "#{SECRET_ACCESS_KEY}",
  # Provide your Cloudflare account ID
  endpoint: "https://#{ACCOUNT_ID}.r2.cloudflarestorage.com",
  region: "auto", # Required by SDK but not used by R2
)

# List all buckets on your account
puts @r2.list_buckets

#=> {
#=>   :buckets => [{
#=>     :name => "your-bucket",
#=>     :creation_date => "…",
#=>   }],
#=>   :owner => {
#=>     :display_name => "…",
#=>     :id => "…"
#=>   }
#=> }

# List the first 20 items in a bucket
puts @r2.list_objects_v2(bucket: "your-bucket", max_keys: 20)

#=> {
#=>   :is_truncated => false,
#=>   :name => "your-bucket",
#=>   :prefix => nil,
#=>   :delimiter => nil,
#=>   :max_keys => 20,
#=>   :common_prefixes => [],
#=>   :encoding_type => nil,
#=>   :key_count => 3,
#=>   :contents => [
#=>     …,
#=>     …,
#=>     …,
#=>   ]
#=> }

aws-sdk-rust

import { Render } from "~/components";


This example uses the aws-sdk-s3 crate from the AWS SDK for Rust. You must pass in the R2 configuration credentials when instantiating your S3 client:

Basic Usage

use aws_sdk_s3 as s3;
use aws_smithy_types::date_time::Format::DateTime;

#[tokio::main]
async fn main() -> Result<(), s3::Error> {
    let bucket_name = "sdk-example";
    // Provide your Cloudflare account ID
    let account_id = "<ACCOUNT_ID>";
    // Retrieve your S3 API credentials for your R2 bucket via API tokens
    // (see: https://developers.cloudflare.com/r2/api/tokens)
    let access_key_id = "<ACCESS_KEY_ID>";
    let access_key_secret = "<SECRET_ACCESS_KEY>";

    // Configure the client
    let config = aws_config::from_env()
        .endpoint_url(format!("https://{}.r2.cloudflarestorage.com", account_id))
        .credentials_provider(aws_sdk_s3::config::Credentials::new(
            access_key_id,
            access_key_secret,
            None, // session token is not used with R2
            None,
            "R2",
        ))
        .region("auto") // Required by SDK but not used by R2
        .load()
        .await;

    let client = s3::Client::new(&config);

    // List buckets
    let list_buckets_output = client.list_buckets().send().await?;

    println!("Buckets:");
    for bucket in list_buckets_output.buckets() {
        println!("  - {}: {}",
            bucket.name().unwrap_or_default(),
            bucket.creation_date().map_or_else(
                || "Unknown creation date".to_string(),
                |date| date.fmt(DateTime).unwrap()
            )
        );
    }

    // List objects in a specific bucket
    let list_objects_output = client
        .list_objects_v2()
        .bucket(bucket_name)
        .send()
        .await?;

    println!("\nObjects in {}:", bucket_name);
    for object in list_objects_output.contents() {
        println!("  - {}: {} bytes, last modified: {}",
            object.key().unwrap_or_default(),
            object.size().unwrap_or_default(),
            object.last_modified().map_or_else(
                || "Unknown".to_string(),
                |date| date.fmt(DateTime).unwrap()
            )
        );
    }

    Ok(())
}

Upload Objects

To upload an object to R2:

use aws_sdk_s3::primitives::ByteStream;
use std::path::Path;

async fn upload_object(
    client: &s3::Client,
    bucket: &str,
    key: &str,
    file_path: &str,
) -> Result<(), s3::Error> {
    let body = ByteStream::from_path(Path::new(file_path)).await.unwrap();

    client
        .put_object()
        .bucket(bucket)
        .key(key)
        .body(body)
        .send()
        .await?;

    println!("Uploaded {} to {}/{}", file_path, bucket, key);
    Ok(())
}

Download Objects

To download an object from R2:

use std::fs;
use std::io::Write;

async fn download_object(
    client: &s3::Client,
    bucket: &str,
    key: &str,
    output_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let resp = client
        .get_object()
        .bucket(bucket)
        .key(key)
        .send()
        .await?;

    let data = resp.body.collect().await?;
    let bytes = data.into_bytes();

    let mut file = fs::File::create(output_path)?;
    file.write_all(&bytes)?;

    println!("Downloaded {}/{} to {}", bucket, key, output_path);
    Ok(())
}

Generate Presigned URLs

You can also generate presigned links that can be used to temporarily share public read or write access to a bucket.

use aws_sdk_s3::presigning::PresigningConfig;
use std::time::Duration;

async fn generate_get_presigned_url(
    client: &s3::Client,
    bucket: &str,
    key: &str,
    expires_in: Duration,
) -> Result<String, s3::Error> {
    let presigning_config = PresigningConfig::expires_in(expires_in)?;

    // Generate a presigned URL for GET (download)
    let presigned_get_request = client
        .get_object()
        .bucket(bucket)
        .key(key)
        .presigned(presigning_config)
        .await?;

    Ok(presigned_get_request.uri().to_string())
}

async fn generate_upload_presigned_url(
    client: &s3::Client,
    bucket: &str,
    key: &str,
    expires_in: Duration,
) -> Result<String, s3::Error> {
    let presigning_config = PresigningConfig::expires_in(expires_in)?;

    // Generate a presigned URL for PUT (upload)
    let presigned_put_request = client
        .put_object()
        .bucket(bucket)
        .key(key)
        .presigned(presigning_config)
        .await?;

    Ok(presigned_put_request.uri().to_string())
}

You can use these presigned URLs with any HTTP client. For example, to upload a file using the PUT URL:

curl -X PUT "https://<your-presigned-put-url>" -H "Content-Type: application/octet-stream" --data-binary "@local-file.txt"

To download a file using the GET URL:

curl -X GET "https://<your-presigned-get-url>" -o downloaded-file.txt

aws4fetch

import { Render } from "~/components";


JavaScript or TypeScript users may continue to use the aws4fetch npm package as per normal. This package uses the fetch and SubtleCrypto APIs which you will be familiar with when working in browsers or with Cloudflare Workers.

aws4fetch is a lightweight option for Workers because it has no Node.js dependencies. You can also use it to generate presigned URLs from a Worker.

You must pass in the R2 configuration credentials when instantiating your S3 service client:

import { AwsClient } from "aws4fetch";

// Provide your Cloudflare account ID
const R2_URL = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;

const client = new AwsClient({
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	accessKeyId: ACCESS_KEY_ID,
	secretAccessKey: SECRET_ACCESS_KEY,
});

const ListBucketsResult = await client.fetch(R2_URL);
console.log(await ListBucketsResult.text());
// <ListAllMyBucketsResult>
//     <Buckets>
//         <Bucket>
//             <CreationDate>2022-04-13T21:23:47.102Z</CreationDate>
//             <Name>user-uploads</Name>
//         </Bucket>
//         <Bucket>
//             <CreationDate>2022-05-07T02:46:49.218Z</CreationDate>
//             <Name>my-bucket</Name>
//         </Bucket>
//     </Buckets>
//     <Owner>
//         <DisplayName>...</DisplayName>
//         <ID>...</ID>
//     </Owner>
// </ListAllMyBucketsResult>

const ListObjectsV2Result = await client.fetch(
	`${R2_URL}/my-bucket?list-type=2`,
);
console.log(await ListObjectsV2Result.text());
// <ListBucketResult>
//   <Name>my-bucket</Name>
//   <Contents>
//     <Key>cat.png</Key>
//     <Size>751832</Size>
//     <LastModified>2022-05-07T02:50:45.616Z</LastModified>
//     <ETag>"c4da329b38467509049e615c11b0c48a"</ETag>
//     <StorageClass>STANDARD</StorageClass>
//   </Contents>
//   <Contents>
//     <Key>todos.txt</Key>
//     <Size>278</Size>
//     <LastModified> 2022-05-07T21:37:17.150Z</LastModified>
//     <ETag>"29d911f495d1ba7cb3a4d7d15e63236a"</ETag>
//     <StorageClass>STANDARD</StorageClass>
//   </Contents>
//   <IsTruncated>false</IsTruncated>
//   <MaxKeys>1000</MaxKeys>
//   <KeyCount>2</KeyCount>
// </ListBucketResult>

Generate presigned URLs

You can also generate presigned links that can be used to share public read or write access to a bucket temporarily.

import { AwsClient } from "aws4fetch";

const client = new AwsClient({
	service: "s3", // Required by SDK but not used by R2
	region: "auto", // Required by SDK but not used by R2
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	accessKeyId: ACCESS_KEY_ID,
	secretAccessKey: SECRET_ACCESS_KEY,
});

// Provide your Cloudflare account ID
const R2_URL = `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`;

// Use the `X-Amz-Expires` query param to determine how long the presigned link is valid.
console.log(
	(
		await client.sign(
			new Request(`${R2_URL}/my-bucket/dog.png?X-Amz-Expires=${3600}`),
			{
				aws: { signQuery: true },
			},
		)
	).url.toString(),
);
// You can also create links for operations such as PutObject to allow temporary write access to a specific key.
// Specify Content-Type header to restrict uploads to a specific file type.
console.log(
	(
		await client.sign(
			new Request(`${R2_URL}/my-bucket/dog.png?X-Amz-Expires=${3600}`, {
				method: "PUT",
				headers: {
					"Content-Type": "image/png",
				},
			}),
			{
				aws: { signQuery: true },
			},
		)
	).url.toString(),
);
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Expires=3600&X-Amz-Date=<timestamp>&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Expires=3600&X-Amz-Date=<timestamp>&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=<signature>
curl -X PUT "https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Expires=3600&X-Amz-Date=<timestamp>&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=<credential>&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=<signature>" \
  -H "Content-Type: image/png" \
  --data-binary @dog.png

Restrict uploads with CORS and Content-Type

When generating presigned URLs for uploads, you can limit abuse and misuse by:

  1. Restricting Content-Type: Specify the Content-Type header in the request when signing. The upload will fail if the client sends a different Content-Type header.

  2. Configuring CORS: Set up CORS rules on your bucket to control which origins can upload files. Configure CORS via the Cloudflare dashboard by adding a JSON policy to your bucket settings:

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Then generate a presigned URL with a Content-Type restriction:

const signedRequest = await client.sign(
	new Request(`${R2_URL}/my-bucket/user-upload.png?X-Amz-Expires=${3600}`, {
		method: "PUT",
		headers: {
			"Content-Type": "image/png",
		},
	}),
	{
		aws: { signQuery: true },
	},
);
const putUrl = signedRequest.url.toString();

When a client uses this presigned URL, they must:

boto3

import { Render } from "~/components";


Configure boto3 to use your R2 endpoint:

import boto3

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

You can omit aws_access_key_id and aws_secret_access_key if you set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.

Common operations using the client:

# Get object metadata
s3.head_object(Bucket="my-bucket", Key="dog.png")

# Get object
response = s3.get_object(Bucket="my-bucket", Key="dog.png")

# Upload single file
with open("./dog.png", "rb") as f:
    s3.upload_fileobj(f, "my-bucket", "dog.png")

# Delete object
s3.delete_object(Bucket="my-bucket", Key="dog.png")

Optimizing upload performance

For large objects (multi-GB files such as training data or video), upload_fileobj can become a throughput bottleneck. Its internal thread pool is limited by Python's GIL, and increasing max_concurrency via TransferConfig gives diminishing returns beyond ~10 threads.

Use the low-level multipart API with ThreadPoolExecutor instead:

import boto3
import math
import os
from concurrent.futures import ThreadPoolExecutor

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

bucket = "my-bucket"
key = "large-file.bin"
file_path = "./large-file.bin"
part_size = 16 * 1024 * 1024  # 16 MiB per part
max_workers = 10

# Step 1: Create the multipart upload
upload_id = None
mpu = s3.create_multipart_upload(Bucket=bucket, Key=key)
upload_id = mpu["UploadId"]

def upload_part(part_number, data):
    response = s3.upload_part(
        Bucket=bucket,
        Key=key,
        UploadId=upload_id,
        PartNumber=part_number,
        Body=data,
    )
    return {"PartNumber": part_number, "ETag": response["ETag"]}

try:
    file_size = os.path.getsize(file_path)
    part_count = math.ceil(file_size / part_size)

    # Step 2: Upload parts in parallel
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = []
        with open(file_path, "rb") as f:
            for i in range(part_count):
                data = f.read(part_size)
                futures.append(pool.submit(upload_part, i + 1, data))

        parts = [future.result() for future in futures]

    # Step 3: Complete the upload
    s3.complete_multipart_upload(
        Bucket=bucket,
        Key=key,
        UploadId=upload_id,
        MultipartUpload={"Parts": parts},
    )
    print("Multipart upload complete.")
except Exception:
    if upload_id:
        try:
            s3.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id)
        except Exception:
            pass
    raise

For more on multipart uploads including part size limits and lifecycle management, refer to Upload objects.

Generate presigned URLs

Generate presigned links to share temporary public read or write access to a bucket.

# Generate presigned URL for reading (GET)
get_url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "my-bucket", "Key": "dog.png"},
    ExpiresIn=3600,  # Valid for 1 hour
)

# Generate presigned URL for writing (PUT)
put_url = s3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "my-bucket",
        "Key": "dog.png",
        "ContentType": "image/png",
    },
    ExpiresIn=3600,
)
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=<signature>
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=<timestamp>&X-Amz-Expires=3600&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=<signature>

Upload using the presigned PUT URL. When using a presigned URL with ContentType, the client must include a matching Content-Type header:

curl -X PUT "https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/dog.png?X-Amz-Algorithm=..." \
  -H "Content-Type: image/png" \
  --data-binary @dog.png

Restrict uploads with CORS and Content-Type

When generating presigned URLs for uploads, you can limit abuse and misuse by:

  1. Restricting Content-Type: Specify the allowed content type in the presigned URL parameters. The upload will fail if the client sends a different Content-Type header.

  2. Configuring CORS: Set up CORS rules on your bucket to control which origins can upload files. Configure CORS via the Cloudflare dashboard by adding a JSON policy to your bucket settings:

[
	{
		"AllowedOrigins": ["https://example.com"],
		"AllowedMethods": ["PUT"],
		"AllowedHeaders": ["Content-Type"],
		"ExposeHeaders": ["ETag"],
		"MaxAgeSeconds": 3600
	}
]

Then generate a presigned URL with a Content-Type restriction:

put_url = s3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "my-bucket",
        "Key": "dog.png",
        "ContentType": "image/png",
    },
    ExpiresIn=3600,
)

When a client uses this presigned URL, they must:

Configure custom headers

Some of R2's extensions require setting a specific header when using them in the S3 compatible API. For some functionality you may want to set a request header on an entire category of requests. Other times you may want to configure a different header for each individual request. This page contains some examples on how to do so with boto3 and with aws-sdk-js-v3.

Setting a custom header on all requests

When using certain functionality, like the cf-create-bucket-if-missing header, you may want to set a constant header for all PutObject requests you're making.

Set a header for all requests with boto3

Boto3 has an event system which allows you to modify requests. Here we register a function into the event system which adds our header to every PutObject request being made.

import boto3

client = boto3.resource('s3',
  # Provide your Cloudflare account ID
  endpoint_url = 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
  # Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
  aws_access_key_id = '<ACCESS_KEY_ID>',
  aws_secret_access_key = '<SECRET_ACCESS_KEY>'
)

event_system = client.meta.events

# Define function responsible for adding the header
def add_custom_header(params, **kwargs):
    params["headers"]['cf-create-bucket-if-missing'] = 'true'

event_system.register('before-call.s3.PutObject', add_custom_header)

response = client.put_object(Bucket="my-bucket", Key="my_file", Body="file_contents")
print(response)

Set a header for all requests with aws-sdk-js-v3

aws-sdk-js-v3 allows the customization of request behavior through the use of its middleware stack. This example adds a middleware to the client which adds a header to every PutObject request being made.

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { HttpRequest } from "@smithy/core/protocols";

const client = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: ACCESS_KEY_ID,
		secretAccessKey: SECRET_ACCESS_KEY,
	},
});

client.middlewareStack.add(
	(next) => async (args) => {
		const request = args.request;

		if (HttpRequest.isInstance(request)) {
			request.headers["cf-create-bucket-if-missing"] = "true";
		}

		return next(args);
	},
	{ step: "build", name: "customHeaders" },
);

const command = new PutObjectCommand({
	Bucket: "my-bucket",
	Key: "my_key",
	Body: "my_data",
});

const response = await client.send(command);

console.log(response);

Set a different header on each request

Certain extensions that R2 has provided in the S3 compatible api may require setting a different header on each request. For example, you may want to only want to overwrite an object if its etag matches a certain expected value. This value will likely be different for each object that is being overwritten, which requires the If-Match header to be different with each request you make. This section shows examples of how to accomplish that.

Set a header per request in boto3

To enable us to pass custom headers as an extra argument into the call to client.put_object() we need to register 2 functions into boto3's event system. This is necessary because boto3 performs a parameter validation step which rejects extra method arguments. Since this parameter validation occurs before we can set headers on the request, we first need to move the custom argument into the request context before the parameter validation happens. In a subsequent step we can now actually set the headers based on the information we put in the request context.

import boto3

client = boto3.resource('s3',
  # Provide your Cloudflare account ID
  endpoint_url = 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
  # Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
  aws_access_key_id = '<ACCESS_KEY_ID>',
  aws_secret_access_key = '<SECRET_ACCESS_KEY>'
)

event_system = client.meta.events

# Moves the custom headers from the parameters to the request context
def process_custom_arguments(params, context, **kwargs):
    if (custom_headers := params.pop("custom_headers", None)):
        context["custom_headers"] = custom_headers

# Here we extract the headers from the request context and actually set them
def add_custom_headers(params, context, **kwargs):
    if (custom_headers := context.get("custom_headers")):
        params["headers"].update(custom_headers)

event_system.register('before-parameter-build.s3.PutObject', process_custom_arguments)
event_system.register('before-call.s3.PutObject', add_custom_headers)

custom_headers = {'If-Match' : '"29d911f495d1ba7cb3a4d7d15e63236a"'}

# Note that boto3 will throw an exception if the precondition failed. Catch this exception if necessary
response = client.put_object(Bucket="my-bucket", Key="my_key", Body="file_contents", custom_headers=custom_headers)
print(response)

Set a header per request in aws-sdk-js-v3

Here we again configure the header we would like to set by creating a middleware, but this time we add the middleware to the request itself instead of to the whole client.

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { HttpRequest } from "@smithy/core/protocols";

const client = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	// Provide your Cloudflare account ID
	endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: ACCESS_KEY_ID,
		secretAccessKey: SECRET_ACCESS_KEY,
	},
});

const command = new PutObjectCommand({
	Bucket: "my-bucket",
	Key: "my_key",
	Body: "my_data",
});

const headers = { "If-Match": '"29d911f495d1ba7cb3a4d7d15e63236a"' };
command.middlewareStack.add(
	(next) => (args) => {
		const request = args.request;

		if (HttpRequest.isInstance(request)) {
			Object.assign(request.headers, headers);
		}

		return next(args);
	},
	{ step: "build", name: "customHeaders" },
);
const response = await client.send(command);

console.log(response);

S3 SDKs

import { DirectoryListing } from "~/components";

s3mini

import { Render } from "~/components";


s3mini is a zero-dependency, lightweight (~20 KB minified) TypeScript S3 client that uses AWS SigV4 signing. It runs natively on Node.js, Bun, and Cloudflare Workers without polyfills.

Unlike the AWS SDKs, s3mini expects a bucket-scoped endpoint — the bucket name is part of the endpoint URL, so you do not pass a separate bucket parameter to each operation.

Install

npm install s3mini

Node.js / Bun

import { S3mini } from "s3mini";

const s3 = new S3mini({
	accessKeyId: process.env.R2_ACCESS_KEY_ID!,
	secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
	// Bucket-scoped endpoint — include your bucket name in the path
	endpoint: `https://${process.env.ACCOUNT_ID}.r2.cloudflarestorage.com/my-bucket`,
	region: "auto",
});

// Upload an object
await s3.putObject("hello.txt", "Hello from s3mini!");

// Download an object as a string
const text = await s3.getObject("hello.txt");
console.log(text);

// List objects with a prefix
const objects = await s3.listObjects("/", "hello");
console.log(objects);

// Delete an object
await s3.deleteObject("hello.txt");

Cloudflare Workers

:::note[Prefer R2 bindings inside Workers] When your Worker and R2 bucket live in the same Cloudflare account, R2 bindings give you zero-latency access without managing API credentials. Use the S3 API when you need cross-account access or interoperability with S3-compatible tooling. :::

s3mini works natively in Workers without the nodejs_compat compatibility flag.

import { S3mini } from "s3mini";

interface Env {
	R2_ACCESS_KEY_ID: string;
	R2_SECRET_ACCESS_KEY: string;
	ACCOUNT_ID: string;
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const s3 = new S3mini({
			accessKeyId: env.R2_ACCESS_KEY_ID,
			secretAccessKey: env.R2_SECRET_ACCESS_KEY,
			endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/my-bucket`,
			region: "auto",
		});

		const url = new URL(request.url);
		const key = url.pathname.slice(1); // strip leading "/"

		if (!key) {
			return new Response("Missing object key", { status: 400 });
		}

		switch (request.method) {
			case "PUT": {
				const data = await request.arrayBuffer();
				const contentType =
					request.headers.get("content-type") ?? "application/octet-stream";
				await s3.putObject(key, new Uint8Array(data), contentType);
				return new Response("Created", { status: 201 });
			}

			case "GET": {
				const response = await s3.getObjectResponse(key);
				if (!response) {
					return new Response("Not Found", { status: 404 });
				}
				return new Response(response.body, {
					headers: {
						"content-type":
							response.headers.get("content-type") ??
							"application/octet-stream",
						etag: response.headers.get("etag") ?? "",
					},
				});
			}

			case "DELETE": {
				await s3.deleteObject(key);
				return new Response(null, { status: 204 });
			}

			default:
				return new Response("Method Not Allowed", { status: 405 });
		}
	},
};

CLI

import { PackageManagers, LinkCard, Render, Tabs, TabItem, Steps } from "~/components";

Manage R2 buckets and objects directly from your terminal. Use CLI tools to automate tasks and manage objects.

Tool Best for
Wrangler Single object operations and managing bucket settings with minimal setup
rclone Bulk object operations, migrations, and syncing directories
AWS CLI Existing AWS workflows or familiarity with AWS CLI

1. Create a bucket

2. Generate API credentials

CLI tools that use the S3 API (AWS CLI, rclone) require an Access Key ID and Secret Access Key. If you are using Wrangler, you can skip this step.

1. In the Cloudflare dashboard, go to **R2**. 2. Select **Manage R2 API tokens**. 3. Select **Create API token**. 4. Choose **Object Read & Write** permission and select the buckets you want to access. 5. Select **Create API Token**. 6. Copy the **Access Key ID** and **Secret Access Key**. Store these securely — you cannot view the secret again.

3. Set up a CLI tool

Wrangler is the Cloudflare Workers CLI. It authenticates with your Cloudflare account directly, so no API credentials needed.

1. Install Wrangler:
  1. Log in to your Cloudflare account:

    wrangler login
    

rclone is ideal for bulk uploads, migrations, and syncing directories.

1. [Install rclone](https://rclone.org/install/) (version 1.59 or later). 2. Configure a new remote:
rclone config
  1. Create new remote by selecting n.
  2. Name your remote r2
  3. Select Amazon S3 Compliant Storage Providers as the storage type.
  4. Select Cloudflare R2 as the provider.
  5. Select whether you would like to enter AWS credentials manually, or get it from the runtime environment.
  6. Enter your Access Key ID and Secret Access Key when prompted.
  7. Select the region to connect to (optional).
  8. Provide your S3 API endpoint.

The AWS CLI works with R2 by specifying a custom endpoint.

1. [Install the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) for your operating system. 2. Configure your credentials:
aws configure
  1. When prompted, enter:

    • AWS Access Key ID: Your R2 Access Key ID
    • AWS Secret Access Key: Your R2 Secret Access Key
    • Default region name: auto
    • Default output format: json (or press Enter to skip)

4. Upload and download objects

(Optional) Create a test file to upload. Run this command in the directory where you plan to run the CLI commands:

echo 'Hello, R2!' > myfile.txt
# Upload myfile.txt to my-bucket
wrangler r2 object put my-bucket/myfile.txt --file ./myfile.txt

# Download myfile.txt and save it as downloaded.txt
wrangler r2 object get my-bucket/myfile.txt --file ./downloaded.txt

Refer to the Wrangler R2 commands for all available operations.

# Upload myfile.txt to my-bucket
rclone copy myfile.txt r2:my-bucket/

# Download myfile.txt from my-bucket to the current directory
rclone copy r2:my-bucket/myfile.txt .

Refer to the rclone documentation for more configuration options.

# Upload myfile.txt to my-bucket
aws s3 cp myfile.txt s3://my-bucket/ --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com

# Download myfile.txt from my-bucket to current directory
aws s3 cp s3://my-bucket/myfile.txt ./ --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com

# List all objects in my-bucket
aws s3 ls s3://my-bucket/ --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com

Refer to the AWS CLI documentation for more examples.

Next steps

Get started

import { DashButton, LinkCard } from "~/components";

Cloudflare R2 Storage allows developers to store large amounts of unstructured data without the costly egress bandwidth fees associated with typical cloud storage services.

Before you begin

You need a Cloudflare account with an R2 subscription. If you do not have one:

  1. Go to the Cloudflare Dashboard.
  2. Select Storage & databases > R2 > Overview
  3. Complete the checkout flow to add an R2 subscription to your account.

R2 is free to get started with included free monthly usage. You are billed for your usage on a monthly basis. Refer to Pricing for details.

Choose how to access R2

R2 supports multiple access methods, so you can choose the one that fits your use case best:

Method Use when
Workers API You are building an application on Cloudflare Workers that needs to read or write from R2
S3 You want to use S3-compatible SDKs to interact with R2 in your existing applications
CLI tools You want to upload, download, or manage objects from your terminal
Dashboard You want to quickly view and manage buckets and objects in the browser

Next steps

S3

import { LinkCard, PackageManagers, Render, Tabs, TabItem, Steps } from "~/components";

R2 provides support for a S3-compatible API, which means you can use any S3 SDK, library, or tool to interact with your buckets. If you have existing code that works with S3, you can use it with R2 by changing the endpoint URL.

1. Create a bucket

2. Generate API credentials

To use the S3 API, you need to generate credentials and get an Access Key ID and Secret Access Key:

You also need your S3 API endpoint URL which you can find at the bottom of the Create API Token confirmation page once you have created your token, or on the R2 Overview page:

https://<ACCOUNT_ID>.r2.cloudflarestorage.com

3. Use an AWS SDK

The following examples show how to use Python and JavaScript SDKs. For other languages, refer to S3-compatible SDK examples for Go, Java, PHP, Ruby, and Rust.

  1. Install boto3:

    pip install boto3
    
  2. Create a test file to upload:

    echo 'Hello, R2!' > myfile.txt
    
  3. Use your credentials to create an S3 client and interact with your bucket:

    import boto3
    
    s3 = boto3.client(
        service_name='s3',
        # Provide your R2 endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
        endpoint_url='https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
        # Provide your R2 Access Key ID and Secret Access Key
        aws_access_key_id='<ACCESS_KEY_ID>',
        aws_secret_access_key='<SECRET_ACCESS_KEY>',
        region_name='auto',  # Required by boto3, not used by R2
    )
    
    # Upload a file
    s3.upload_file('myfile.txt', 'my-bucket', 'myfile.txt')
    print('Uploaded myfile.txt')
    
    # Download a file
    s3.download_file('my-bucket', 'myfile.txt', 'downloaded.txt')
    print('Downloaded to downloaded.txt')
    
    # List objects
    response = s3.list_objects_v2(Bucket='my-bucket')
    for obj in response.get('Contents', []):
        print(f"Object: {obj['Key']}")
    
  4. Save this as example.py and run it:

    python example.py
    
    Uploaded myfile.txt
    Downloaded to downloaded.txt
    Object: myfile.txt
    

Refer to boto3 examples for more operations.

  1. Install the @aws-sdk/client-s3 package:

    npm install @aws-sdk/client-s3
    
  2. Use your credentials to create an S3 client and interact with your bucket:

    import {
    	S3Client,
    	PutObjectCommand,
    	GetObjectCommand,
    	ListObjectsV2Command,
    } from "@aws-sdk/client-s3";
    
    const s3 = new S3Client({
    	region: "auto", // Required by AWS SDK, not used by R2
    	// Provide your R2 endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
    	endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    	credentials: {
    		// Provide your R2 Access Key ID and Secret Access Key
    		accessKeyId: "<ACCESS_KEY_ID>",
    		secretAccessKey: "<SECRET_ACCESS_KEY>",
    	},
    });
    
    // Upload a file
    await s3.send(
    	new PutObjectCommand({
    		Bucket: "my-bucket",
    		Key: "myfile.txt",
    		Body: "Hello, R2!",
    	}),
    );
    console.log("Uploaded myfile.txt");
    
    // Download a file
    const response = await s3.send(
    	new GetObjectCommand({
    		Bucket: "my-bucket",
    		Key: "myfile.txt",
    	}),
    );
    const content = await response.Body.transformToString();
    console.log("Downloaded:", content);
    
    // List objects
    const list = await s3.send(
    	new ListObjectsV2Command({
    		Bucket: "my-bucket",
    	}),
    );
    console.log(
    	"Objects:",
    	list.Contents.map((obj) => obj.Key),
    );
    
  3. Save this as example.mjs and run it:

    node example.mjs
    
    Uploaded myfile.txt
    Downloaded: Hello, R2!
    Objects: [ 'myfile.txt' ]
    

Refer to AWS SDK for JavaScript examples for more operations.

Next steps

Workers API

import { PackageManagers, WranglerConfig, LinkCard, Render, Tabs, TabItem, Steps } from "~/components";

Workers let you run code at the edge. When you bind an R2 bucket to a Worker, you can read and write objects directly using the Workers API.

1. Create a bucket

2. Create a Worker with an R2 binding

  1. Create a new Worker project:

    <PackageManagers type="create" pkg="cloudflare@latest" args={"r2-worker"} />

    When prompted, select Hello World example and JavaScript (or TypeScript) as your template.

  2. Move into the project directory:

    cd r2-worker
    
  3. Add an R2 binding to your Wrangler configuration file. Replace my-bucket with your bucket name:

    {
      "r2_buckets": [
        {
          "binding": "MY_BUCKET",
          "bucket_name": "my-bucket"
        }
      ]
    }
    
  4. (Optional) If you are using TypeScript, regenerate types:

    npx wrangler types
    

3. Read and write objects

Use the binding to interact with your bucket. This example stores and retrieves objects based on the URL path:

export default {
	async fetch(request, env) {
		// Get the object key from the URL path
		// For example: /images/cat.png → images/cat.png
		const url = new URL(request.url);
		const key = url.pathname.slice(1);

		// PUT: Store the request body in R2
		if (request.method === "PUT") {
			await env.MY_BUCKET.put(key, request.body);
			return new Response(`Put ${key} successfully!`);
		}

		// GET: Retrieve the object from R2
		const object = await env.MY_BUCKET.get(key);
		if (object === null) {
			return new Response("Object not found", { status: 404 });
		}
		return new Response(object.body);
	},
};
export default {
	async fetch(request, env): Promise<Response> {
		// Get the object key from the URL path
		// For example: /images/cat.png → images/cat.png
		const url = new URL(request.url);
		const key = url.pathname.slice(1);

		// PUT: Store the request body in R2
		if (request.method === "PUT") {
			await env.MY_BUCKET.put(key, request.body);
			return new Response(`Put ${key} successfully!`);
		}

		// GET: Retrieve the object from R2
		const object = await env.MY_BUCKET.get(key);
		if (object === null) {
			return new Response("Object not found", { status: 404 });
		}
		return new Response(object.body);
	},
} satisfies ExportedHandler<Env>;

4. Test and deploy

  1. Test your Worker locally:

    npx wrangler dev
    

    :::note[Local development] By default, wrangler dev uses a local R2 simulation. Objects you store during development exist only on your machine in the .wrangler/state folder and do not affect your production bucket.

    To connect to your real R2 bucket during development, add "remote": true to your R2 binding in your Wrangler configuration file. Refer to remote bindings for more information. :::

  2. Once the dev server is running, test storing and retrieving objects:

    # Store an object
    curl -X PUT http://localhost:8787/my-file.txt -d 'Hello, R2!'
    
    # Retrieve the object
    curl http://localhost:8787/my-file.txt
    
  3. Deploy to production:

    npx wrangler deploy
    
  4. After deploying, Wrangler outputs your Worker's URL (for example, https://r2-worker.<YOUR_SUBDOMAIN>.workers.dev). Test storing and retrieving objects:

    # Store an object
    curl -X PUT https://r2-worker.<YOUR_SUBDOMAIN>.workers.dev/my-file.txt -d 'Hello, R2!'
    
    # Retrieve the object
    curl https://r2-worker.<YOUR_SUBDOMAIN>.workers.dev/my-file.txt
    

Refer to the Workers R2 API documentation for the complete API reference.

Next steps

Delete objects

import { Tabs, TabItem, DashButton, LinkCard } from "~/components";

You can delete objects from R2 using the dashboard, Workers API, S3 API, or command-line tools. To empty or delete an entire bucket, refer to Delete buckets.

Delete via dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select your bucket.

  3. (Optional) Select the View prefixes as directories checkbox to view prefixes grouped as folders.

  4. Select the objects or folders you want to delete. You can select a mix of both in the same operation.

  5. Select Delete.

  6. Confirm your choice in the dialog that appears.

To delete all objects in a bucket at once, refer to Empty a bucket.

Delete via Workers API

Use R2 bindings in Workers to delete objects:

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext) {
		await env.MY_BUCKET.delete("image.png");
		return new Response("Deleted");
	},
} satisfies ExportedHandler<Env>;

For complete documentation, refer to Workers API.

Delete via S3 API

Use S3-compatible SDKs to delete objects. You'll need your account ID and R2 API token.

import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";

const S3 = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	// Provide your Cloudflare account ID
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: '<ACCESS_KEY_ID>',
		secretAccessKey: '<SECRET_ACCESS_KEY>',
	},
});

await S3.send(
	new DeleteObjectCommand({
		Bucket: "my-bucket",
		Key: "image.png",
	}),
);
import boto3

s3 = boto3.client(
	service_name="s3",
	# Provide your Cloudflare account ID
	endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
	# Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	aws_access_key_id=ACCESS_KEY_ID,
	aws_secret_access_key=SECRET_ACCESS_KEY,
	region_name="auto", # Required by SDK but not used by R2
)

s3.delete_object(Bucket="my-bucket", Key="image.png")

For complete S3 API documentation, refer to S3 API.

Delete via Wrangler

:::caution

Deleting objects from a bucket is irreversible.

:::

Use Wrangler to delete objects. Run the r2 object delete command:

wrangler r2 object delete test-bucket/image.png

Download objects

import { Render, Tabs, TabItem, DashButton } from "~/components";

You can download objects from R2 using the dashboard, Workers API, S3 API, or command-line tools.

Download via dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select your bucket.

  3. Locate the object you want to download.

  4. Select ... for the object and click Download.

Download via Workers API

Use R2 bindings in Workers to download objects:

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext) {
		const object = await env.MY_BUCKET.get("image.png");
		if (object === null) {
			return new Response("Object not found", { status: 404 });
		}
		return new Response(object.body);
	},
} satisfies ExportedHandler<Env>;

For complete documentation, refer to Workers API.

Download via S3 API

Use S3-compatible SDKs to download objects. You'll need your account ID and R2 API token.

import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const S3 = new S3Client({
	region: "auto", // Required by SDK but not used by R2
	// Provide your Cloudflare account ID
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	// Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	credentials: {
		accessKeyId: '<ACCESS_KEY_ID>',
		secretAccessKey: '<SECRET_ACCESS_KEY>',
	},
});

const response = await S3.send(
	new GetObjectCommand({
		Bucket: "my-bucket",
		Key: "image.png",
	}),
);
import boto3

s3 = boto3.client(
	service_name="s3",
	# Provide your Cloudflare account ID
	endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
	# Retrieve your S3 API credentials for your R2 bucket via API tokens (see: https://developers.cloudflare.com/r2/api/tokens)
	aws_access_key_id=ACCESS_KEY_ID,
	aws_secret_access_key=SECRET_ACCESS_KEY,
	region_name="auto", # Required by SDK but not used by R2
)

response = s3.get_object(Bucket="my-bucket", Key="image.png")
image_data = response["Body"].read()

Refer to R2's S3 API documentation for all S3 API methods.

Presigned URLs

For client-side downloads where users download directly from R2, use presigned URLs. Your server generates a temporary download URL that clients can use without exposing your API credentials.

  1. Your application generates a presigned GET URL using an S3 SDK
  2. Send the URL to your client
  3. Client downloads directly from R2 using the presigned URL

For details on generating and using presigned URLs, refer to Presigned URLs.

Download via Wrangler

Use Wrangler to download objects. Run the r2 object get command:

wrangler r2 object get test-bucket/image.png

The file will be downloaded into the current working directory. You can also use the --file flag to set a new name for the object as it is downloaded, and the --pipe flag to pipe the download to standard output (stdout).

Objects

import { DirectoryListing, Render } from "~/components"

Objects are individual files or data that you store in an R2 bucket. Each object is identified by its key, a string like images/photo.png.

Prefixes and folders

R2 uses a flat storage structure. There are no real directories or folders. The / character in an object key is used as a delimiter to group objects by prefix.

The R2 dashboard groups objects that share a common prefix into folders when the View prefixes as directories checkbox is selected. For example, objects with keys logs/jan.csv and logs/feb.csv appear under a logs/ folder. These folders are a visual grouping and do not exist as separate resources in your bucket.

Manage objects

Upload objects

import { Steps, Tabs, TabItem, Render, DashButton, LinkCard, TypeScriptExample, } from "~/components";

There are several ways to upload objects to R2. Which approach you choose depends on the size of your objects and your performance requirements.

Choose an upload method

Single upload (PUT) Multipart upload
Best for Small to medium files (under ~100 MB) Large files, or when you need parallelism and resumability
Maximum object size 5 GiB 5 TiB (up to 10,000 parts)
Part size N/A 5 MiB – 5 GiB per part
Resumable No — must restart the entire upload Yes — only failed parts need to be retried
Parallel upload No Yes — parts can be uploaded concurrently
When to use Quick, simple uploads of small objects Video, backups, datasets, or any file where reliability matters

:::note Most S3-compatible SDKs and tools (such as rclone) automatically choose multipart upload for large files based on a configurable threshold. You do not typically need to implement multipart logic yourself when using the S3 API. :::

Upload via dashboard

To upload objects to your bucket from the Cloudflare dashboard:

1. In the Cloudflare dashboard, go to the **R2 object storage** page. 2. Select your bucket. 3. Select **Upload**. 4. Drag and drop your file into the upload area or **select from computer**.

You will receive a confirmation message after a successful upload.

Create a folder

You can also create folders from the dashboard by selecting Create folder. This creates a zero-byte object with a key ending in / that acts as a placeholder. For more information on how folders work in R2, refer to Prefixes and folders.

Upload via Workers API

Use R2 bindings in Workers to upload objects server-side. Refer to Use R2 from Workers for instructions on setting up an R2 binding.

Single upload

Use put() to upload an object in a single request. This is the simplest approach for small to medium objects.

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		try {
			const object = await env.MY_BUCKET.put("image.png", request.body, {
				httpMetadata: {
					contentType: "image/png",
				},
			});

			if (object === null) {
				return new Response("Precondition failed or upload returned null", {
					status: 412,
				});
			}

			return Response.json({
				key: object.key,
				size: object.size,
				etag: object.etag,
			});
		} catch (err) {
			return new Response(`Upload failed: ${err}`, { status: 500 });
		}
	},
} satisfies ExportedHandler<Env>;

Multipart upload

Use createMultipartUpload() and resumeMultipartUpload() for large files or when you need to upload parts in parallel. Each part must be at least 5 MiB (except the last part).

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const key = "large-file.bin";

		// Create a new multipart upload
		const multipartUpload = await env.MY_BUCKET.createMultipartUpload(key);

		try {
			// In a real application, these would be actual data chunks.
			// Each part except the last must be at least 5 MiB.
			const firstChunk = new Uint8Array(5 * 1024 * 1024); // placeholder
			const secondChunk = new Uint8Array(1024); // placeholder

			const part1 = await multipartUpload.uploadPart(1, firstChunk);
			const part2 = await multipartUpload.uploadPart(2, secondChunk);

			// Complete the upload with all parts
			const object = await multipartUpload.complete([part1, part2]);

			return Response.json({
				key: object.key,
				etag: object.httpEtag,
			});
		} catch (err) {
			// Abort on failure so incomplete uploads do not count against storage
			await multipartUpload.abort();
			return new Response(`Multipart upload failed: ${err}`, { status: 500 });
		}
	},
} satisfies ExportedHandler<Env>;

In most cases, the multipart state (the uploadId and uploaded part ETags) is tracked by the client sending requests to your Worker. The following example exposes an HTTP API that a client application can call to create, upload parts for, and complete a multipart upload:

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const key = url.pathname.slice(1);
		const action = url.searchParams.get("action");

		if (!key || !action) {
			return new Response("Missing key or action", { status: 400 });
		}

		switch (action) {
			// Step 1: Client calls POST /<key>?action=mpu-create
			case "mpu-create": {
				const upload = await env.MY_BUCKET.createMultipartUpload(key);
				return Response.json({ key: upload.key, uploadId: upload.uploadId });
			}

			// Step 2: Client calls PUT /<key>?action=mpu-uploadpart&uploadId=...&partNumber=...
			case "mpu-uploadpart": {
				const uploadId = url.searchParams.get("uploadId");
				const partNumber = Number(url.searchParams.get("partNumber"));
				if (!uploadId || !partNumber || !request.body) {
					return new Response("Missing uploadId, partNumber, or body", {
						status: 400,
					});
				}
				const upload = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
				try {
					const part = await upload.uploadPart(partNumber, request.body);
					return Response.json(part);
				} catch (err) {
					return new Response(String(err), { status: 400 });
				}
			}

			// Step 3: Client calls POST /<key>?action=mpu-complete&uploadId=...
			case "mpu-complete": {
				const uploadId = url.searchParams.get("uploadId");
				if (!uploadId) {
					return new Response("Missing uploadId", { status: 400 });
				}
				const upload = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
				const body = await request.json<{ parts: R2UploadedPart[] }>();
				try {
					const object = await upload.complete(body.parts);
					return new Response(null, {
						headers: { etag: object.httpEtag },
					});
				} catch (err) {
					return new Response(String(err), { status: 400 });
				}
			}

			// Abort an in-progress upload
			case "mpu-abort": {
				const uploadId = url.searchParams.get("uploadId");
				if (!uploadId) {
					return new Response("Missing uploadId", { status: 400 });
				}
				const upload = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
				try {
					await upload.abort();
				} catch (err) {
					return new Response(String(err), { status: 400 });
				}
				return new Response(null, { status: 204 });
			}

			default:
				return new Response(`Unknown action: ${action}`, { status: 400 });
		}
	},
} satisfies ExportedHandler<Env>;

For the complete Workers API reference, refer to Workers API reference.

Presigned URLs (Workers)

When you need clients (browsers, mobile apps) to upload directly to R2 without proxying through your Worker, generate a presigned URL server-side and hand it to the client:

import { AwsClient } from "aws4fetch";

interface Env {
	R2_ACCESS_KEY_ID: string;
	R2_SECRET_ACCESS_KEY: string;
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const r2 = new AwsClient({
			accessKeyId: env.R2_ACCESS_KEY_ID,
			secretAccessKey: env.R2_SECRET_ACCESS_KEY,
		});

		// Generate a presigned PUT URL valid for 1 hour
		const url = new URL(
			"https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/image.png",
		);
		url.searchParams.set("X-Amz-Expires", "3600");

		const signed = await r2.sign(new Request(url, { method: "PUT" }), {
			aws: { signQuery: true },
		});

		// Return the signed URL to the client — they can PUT directly to R2
		return Response.json({ url: signed.url });
	},
} satisfies ExportedHandler<Env>;

For full presigned URL documentation including GET, PUT, and security best practices, refer to Presigned URLs.

Upload via S3 API

Use S3-compatible SDKs to upload objects. You will need your account ID and R2 API token.

Single upload

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { readFile } from "node:fs/promises";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const fileContent = await readFile("./image.png");

const response = await S3.send(
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "image.png",
		Body: fileContent,
		ContentType: "image/png",
	}),
);
console.log(`Uploaded successfully. ETag: ${response.ETag}`);
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { readFile } from "node:fs/promises";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const fileContent = await readFile("./image.png");

const response = await S3.send(
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "image.png",
		Body: fileContent,
		ContentType: "image/png",
	}),
);
console.log(`Uploaded successfully. ETag: ${response.ETag}`);
import boto3

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

with open("./image.png", "rb") as f:
    response = s3.put_object(
        Bucket="my-bucket",
        Key="image.png",
        Body=f,
        ContentType="image/png",
    )
    print(f"Uploaded successfully. ETag: {response['ETag']}")

Multipart upload

Most S3 SDKs handle multipart uploads automatically when the file exceeds a configurable threshold. The examples below show both automatic (high-level) and manual (low-level) approaches.

Automatic multipart upload

The SDK splits the file and uploads parts in parallel.

import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from "node:fs";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const upload = new Upload({
	client: S3,
	params: {
		Bucket: "my-bucket",
		Key: "large-file.bin",
		Body: createReadStream("./large-file.bin"),
	},
	// Upload parts in parallel (default: 4)
	leavePartsOnError: false,
});

upload.on("httpUploadProgress", (progress) => {
	console.log(`Uploaded ${progress.loaded ?? 0} bytes`);
});

const result = await upload.done();
console.log(`Upload complete. ETag: ${result.ETag}`);
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from "node:fs";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const upload = new Upload({
	client: S3,
	params: {
		Bucket: "my-bucket",
		Key: "large-file.bin",
		Body: createReadStream("./large-file.bin"),
	},
	leavePartsOnError: false,
});

upload.on("httpUploadProgress", (progress) => {
	console.log(`Uploaded ${progress.loaded ?? 0} bytes`);
});

const result = await upload.done();
console.log(`Upload complete. ETag: ${result.ETag}`);
import boto3

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

# upload_file automatically uses multipart for large files.
# For better throughput with large objects, use the manual multipart example below.
s3.upload_file(
    Filename="./large-file.bin",
    Bucket="my-bucket",
    Key="large-file.bin",
)

Manual multipart upload

Use the low-level API when you need full control over part sizes or upload order.

import {
	S3Client,
	CreateMultipartUploadCommand,
	UploadPartCommand,
	CompleteMultipartUploadCommand,
	AbortMultipartUploadCommand,
	type CompletedPart,
} from "@aws-sdk/client-s3";
import { createReadStream, statSync } from "node:fs";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const bucket = "my-bucket";
const key = "large-file.bin";
const partSize = 10 * 1024 * 1024; // 10 MiB per part

// Step 1: Create the multipart upload
const { UploadId } = await S3.send(
	new CreateMultipartUploadCommand({ Bucket: bucket, Key: key }),
);

try {
	const fileSize = statSync("./large-file.bin").size;
	const partCount = Math.ceil(fileSize / partSize);
	const parts: CompletedPart[] = [];

	// Step 2: Upload each part
	for (let i = 0; i < partCount; i++) {
		const start = i * partSize;
		const end = Math.min(start + partSize, fileSize);
		const { ETag } = await S3.send(
			new UploadPartCommand({
				Bucket: bucket,
				Key: key,
				UploadId,
				PartNumber: i + 1,
				Body: createReadStream("./large-file.bin", { start, end: end - 1 }),
				ContentLength: end - start,
			}),
		);
		parts.push({ PartNumber: i + 1, ETag });
	}

	// Step 3: Complete the upload
	await S3.send(
		new CompleteMultipartUploadCommand({
			Bucket: bucket,
			Key: key,
			UploadId,
			MultipartUpload: { Parts: parts },
		}),
	);
	console.log("Multipart upload complete.");
} catch (err) {
	// Abort on failure to clean up incomplete parts
	try {
		await S3.send(
			new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId }),
		);
	} catch (_abortErr) {
		// Best-effort cleanup — the original error is more important
	}
	throw err;
}
import {
	S3Client,
	CreateMultipartUploadCommand,
	UploadPartCommand,
	CompleteMultipartUploadCommand,
	AbortMultipartUploadCommand,
} from "@aws-sdk/client-s3";
import { createReadStream, statSync } from "node:fs";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const bucket = "my-bucket";
const key = "large-file.bin";
const partSize = 10 * 1024 * 1024; // 10 MiB per part

// Step 1: Create the multipart upload
const { UploadId } = await S3.send(
	new CreateMultipartUploadCommand({ Bucket: bucket, Key: key }),
);

try {
	const fileSize = statSync("./large-file.bin").size;
	const partCount = Math.ceil(fileSize / partSize);
	const parts = [];

	// Step 2: Upload each part
	for (let i = 0; i < partCount; i++) {
		const start = i * partSize;
		const end = Math.min(start + partSize, fileSize);
		const { ETag } = await S3.send(
			new UploadPartCommand({
				Bucket: bucket,
				Key: key,
				UploadId,
				PartNumber: i + 1,
				Body: createReadStream("./large-file.bin", { start, end: end - 1 }),
				ContentLength: end - start,
			}),
		);
		parts.push({ PartNumber: i + 1, ETag });
	}

	// Step 3: Complete the upload
	await S3.send(
		new CompleteMultipartUploadCommand({
			Bucket: bucket,
			Key: key,
			UploadId,
			MultipartUpload: { Parts: parts },
		}),
	);
	console.log("Multipart upload complete.");
} catch (err) {
	// Abort on failure to clean up incomplete parts
	try {
		await S3.send(
			new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId }),
		);
	} catch (_abortErr) {
		// Best-effort cleanup — the original error is more important
	}
	throw err;
}
import boto3
import math
import os
from concurrent.futures import ThreadPoolExecutor

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

bucket = "my-bucket"
key = "large-file.bin"
file_path = "./large-file.bin"
part_size = 16 * 1024 * 1024  # 16 MiB per part
max_workers = 10  # Number of parallel upload threads

# Step 1: Create the multipart upload
upload_id = None
mpu = s3.create_multipart_upload(Bucket=bucket, Key=key)
upload_id = mpu["UploadId"]

def upload_part(part_number, data):
    response = s3.upload_part(
        Bucket=bucket,
        Key=key,
        UploadId=upload_id,
        PartNumber=part_number,
        Body=data,
    )
    return {"PartNumber": part_number, "ETag": response["ETag"]}

try:
    file_size = os.path.getsize(file_path)
    part_count = math.ceil(file_size / part_size)

    # Step 2: Upload parts in parallel
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = []
        with open(file_path, "rb") as f:
            for i in range(part_count):
                data = f.read(part_size)
                futures.append(pool.submit(upload_part, i + 1, data))

        parts = [future.result() for future in futures]

    # Step 3: Complete the upload
    s3.complete_multipart_upload(
        Bucket=bucket,
        Key=key,
        UploadId=upload_id,
        MultipartUpload={"Parts": parts},
    )
    print("Multipart upload complete.")
except Exception:
    # Abort on failure to clean up incomplete parts
    if upload_id:
        try:
            s3.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id)
        except Exception:
            pass  # Best-effort cleanup — the original error is more important
    raise

Presigned URLs (S3 API)

For client-side uploads where users upload directly to R2 without going through your server, generate a presigned PUT URL. Your server creates the URL and the client uploads to it — no API credentials are exposed to the client.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const presignedUrl = await getSignedUrl(
	S3,
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "user-upload.png",
		ContentType: "image/png",
	}),
	{ expiresIn: 3600 }, // Valid for 1 hour
);

console.log(presignedUrl);
// Return presignedUrl to the client
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const S3 = new S3Client({
	region: "auto",
	endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
	credentials: {
		accessKeyId: "<ACCESS_KEY_ID>",
		secretAccessKey: "<SECRET_ACCESS_KEY>",
	},
});

const presignedUrl = await getSignedUrl(
	S3,
	new PutObjectCommand({
		Bucket: "my-bucket",
		Key: "user-upload.png",
		ContentType: "image/png",
	}),
	{ expiresIn: 3600 }, // Valid for 1 hour
);

console.log(presignedUrl);
// Return presignedUrl to the client
import boto3

s3 = boto3.client(
    service_name="s3",
    endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
    aws_access_key_id="<ACCESS_KEY_ID>",
    aws_secret_access_key="<SECRET_ACCESS_KEY>",
    region_name="auto",
)

presigned_url = s3.generate_presigned_url(
    "put_object",
    Params={
        "Bucket": "my-bucket",
        "Key": "user-upload.png",
        "ContentType": "image/png",
    },
    ExpiresIn=3600,  # Valid for 1 hour
)

print(presigned_url)
# Return presigned_url to the client

For full presigned URL documentation, refer to Presigned URLs.

Refer to R2's S3 API documentation for all supported S3 API methods.

Upload via CLI

Rclone

Rclone is a command-line tool for managing files on cloud storage. Rclone works well for uploading multiple files from your local machine or copying data from other cloud storage providers.

To use rclone, install it onto your machine using their official documentation - Install rclone.

Upload files with the rclone copy command:

# Upload a single file
rclone copy /path/to/local/image.png r2:bucket_name

# Upload everything in a directory
rclone copy /path/to/local/folder r2:bucket_name

Verify the upload with rclone ls:

rclone ls r2:bucket_name

For more information, refer to our rclone example.

Wrangler

:::note

Wrangler supports uploading files up to 315 MB and only allows one object at a time. For large files or bulk uploads, use rclone or another S3-compatible tool.

:::

Use Wrangler to upload objects. Run the r2 object put command:

wrangler r2 object put test-bucket/image.png --file=image.png

You can set the Content-Type (MIME type), Content-Disposition, Cache-Control and other HTTP header metadata through optional flags.

Multipart upload details

Part size limits

Incomplete upload lifecycles

Incomplete multipart uploads are automatically aborted after 7 days by default. You can change this by configuring a custom lifecycle policy.

ETags

ETags for objects uploaded via multipart differ from those uploaded with a single PUT. The ETag of each part is the MD5 hash of that part's contents. The ETag of the completed multipart object is the hash of the concatenated binary MD5 sums of all parts, followed by a hyphen and the number of parts.

For example, if a two-part upload has part ETags bce6bf66aeb76c7040fdd5f4eccb78e6 and 8165449fc15bbf43d3b674595cbcc406, the completed object's ETag will be f77dc0eecdebcd774a2a22cb393ad2ff-2.

Audit Logs

import { DashButton } from "~/components";

Audit logs provide a comprehensive summary of changes made within your Cloudflare account, including those made to R2 buckets. This functionality is available on all plan types, free of charge, and is always enabled.

Viewing audit logs

To view audit logs for your R2 buckets, go to the Audit logs page.

For more information on how to access and use audit logs, refer to Review audit logs.

Logged operations

The following configuration actions are logged:

Operation Description
CreateBucket Creation of a new bucket.
DeleteBucket Deletion of an existing bucket.
AddCustomDomain Addition of a custom domain to a bucket.
RemoveCustomDomain Removal of a custom domain from a bucket.
ChangeBucketVisibility Change to the managed public access (r2.dev) settings of a bucket.
PutBucketStorageClass Change to the default storage class of a bucket.
PutBucketLifecycleConfiguration Change to the object lifecycle configuration of a bucket.
DeleteBucketLifecycleConfiguration Deletion of the object lifecycle configuration for a bucket.
PutBucketCors Change to the CORS configuration for a bucket.
DeleteBucketCors Deletion of the CORS configuration for a bucket.

:::note Audit Logs do not include data access operations, such as GetObject and PutObject. To record supported object operations with response status codes below 400, use R2 Data Access Logs.

Data Access Logs also include supported requests to public R2 buckets through r2.dev or custom domains. :::

Example log entry

Below is an example of an audit log entry showing the creation of a new bucket:

{
	"action": { "info": "CreateBucket", "result": true, "type": "create" },
	"actor": {
		"email": "<ACTOR_EMAIL>",
		"id": "3f7b730e625b975bc1231234cfbec091",
		"ip": "fe32:43ed:12b5:526::1d2:13",
		"type": "user"
	},
	"id": "5eaeb6be-1234-406a-87ab-1971adc1234c",
	"interface": "API",
	"metadata": { "zone_name": "r2.cloudflarestorage.com" },
	"newValue": "",
	"newValueJson": {},
	"oldValue": "",
	"oldValueJson": {},
	"owner": { "id": "1234d848c0b9e484dfc37ec392b5fa8a" },
	"resource": { "id": "my-bucket", "type": "r2.bucket" },
	"when": "2024-07-15T16:32:52.412Z"
}

Event subscriptions

import { Render } from "~/components";

Available R2 events

Available Super Slurper events

Limits

import { Render } from "~/components";

Feature Limit
Data storage per bucket Unlimited
Number of objects per bucket Unlimited
Maximum number of buckets per account Unlimited, with a default limit of 100,000. Each bucket above 100,000 costs $0.10 per month.
Maximum rate of bucket management operations per bucket [^1] 50 per second
Number of custom domains per bucket 100
Object key length 1,024 bytes
Object metadata size 8,192 bytes
Object size 5 TiB per object [^2]
Maximum upload size [^3] 5 GiB (single-part) / 4.995 TiB (multi-part) [^4]
Maximum upload parts 10,000
Maximum concurrent writes to the same object name (key) 1 per second [^5]

[^1]: Bucket management operations include creating, deleting, listing, and configuring buckets. This limit does not apply to reading or writing objects to a bucket.

[^2]: The object size limit is 5 GiB less than 5 TiB, so 4.995 TiB.

[^3]: Max upload size applies to uploading a file via one request, uploading a part of a multipart upload, or copying into a part of a multipart upload. If you have a Worker, its inbound request size is constrained by Workers request limits. The max upload size limit does not apply to subrequests.

[^4]: The max upload size is 5 MiB less than 5 GiB, so 4.995 GiB.

[^5]: Concurrent writes to the same object name (key) at a higher rate return HTTP 429 (rate limited) responses.

Limits specified in MiB (mebibyte), GiB (gibibyte), or TiB (tebibyte) are storage units of measurement based on base-2. 1 GiB (gibibyte) is equivalent to 230 bytes (or 10243 bytes). This is distinct from 1 GB (gigabyte), which is 109 bytes (or 10003 bytes).

Rate limiting on managed public buckets through r2.dev

Managed public bucket access through an r2.dev subdomain is not intended for production usage and has a variable rate limit applied to it. The r2.dev endpoint for your bucket is designed to enable testing.

For production use cases, connect a custom domain to your bucket. Custom domains allow you to serve content from a domain you control (for example, assets.example.com), configure fine-grained caching, set up redirect and rewrite rules, mutate content via Cloudflare Workers, and get detailed URL-level analytics for content served from your R2 bucket.

Cloudflare REST API

The Cloudflare REST API is rate limited to 1,200 requests per five minutes across all R2 REST API operations on your account.

For high-throughput object operations, use the S3-compatible API or Workers API instead. The Cloudflare REST API is best suited for lower-volume management and configuration operations.

Metrics and analytics

import { DashButton } from "~/components";

R2 exposes analytics for requests, storage, and bandwidth usage across your buckets.

The metrics displayed for a bucket in the Cloudflare dashboard are queried from Cloudflare's GraphQL Analytics API. You can access the metrics programmatically via GraphQL or HTTP client.

Metrics

R2 has three datasets:

Dataset GraphQL Dataset Name Description
Operations r2OperationsAdaptiveGroups This dataset consists of the operations taken on a bucket within an account.
Storage r2StorageAdaptiveGroups This dataset consists of the storage of a bucket within an account.
Bandwidth r2BandwidthUsageAdaptiveGroups Dataset contains uploaded and downloaded bytes for buckets within an account.

Metrics can be queried for a maximum range of 31 days. These datasets require an accountTag filter with your Cloudflare account ID.

:::caution[Querying buckets with jurisdiction restriction] In your account, you may have two buckets of the same name, one with a specified jurisdiction, and one without.

Therefore, if you want to query metrics about a bucket which has a specified jurisdiction, you must include the jurisdiction followed by an underscore before the bucket name. For example: eu_bucket-name. This ensures you query the correct bucket. :::

Operations dataset

Field Description
actionType The name of the operation performed.
actionStatus The status of the operation. Can be success, userError, or internalError.
bucketName The bucket this operation was performed on if applicable. For buckets with a jurisdiction specified, you must include the jurisdiction followed by an underscore before the bucket name. For example: eu_your-bucket-name
objectName The object this operation was performed on if applicable.
responseStatusCode The http status code returned by this operation.
datetime The time of the request.

Storage dataset

Field Description
bucketName The bucket this storage value is for. For buckets with a jurisdiction specified, you must include the jurisdiction followed by an underscore before the bucket name. For example: eu_your-bucket-name
payloadSize The size of the objects in the bucket.
metadataSize The size of the metadata of the objects in the bucket.
objectCount The number of objects in the bucket.
uploadCount The number of pending multipart uploads in the bucket.
datetime The time that this storage value represents.

Bandwidth dataset

The dataset excludes bandwidth transfers smaller than 100 KiB. This threshold applies to transfer size, not object size.

You can filter by the following fields:

Field Description
bucketName The R2 bucket identifier, including the jurisdiction prefix if applicable.
date The transfer timestamp truncated to the start of the day.
datetimeHour The transfer timestamp truncated to the start of the hour.
datetimeFifteenMinutes The transfer timestamp rounded down to the nearest quarter hour.
datetimeFiveMinutes The transfer timestamp truncated to the start of the five-minute interval.
datetimeMinute The transfer timestamp truncated to the start of the minute.
datetime The raw transfer timestamp.

The following fields can be summed:

Field Description
bytesDownload Downloaded bytes.
bytesUpload Uploaded bytes.

View via the dashboard

Per-bucket analytics for R2 are available in the Cloudflare dashboard. To view current and historical metrics for a bucket:

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select your bucket.

  3. Select the Metrics tab.

You can optionally select a time window to query. This defaults to the last 24 hours.

Query via the GraphQL API

You can programmatically query analytics for your R2 buckets via the GraphQL Analytics API. This API queries the same dataset as the Cloudflare dashboard, and supports GraphQL introspection.

Examples

Operations

To query the volume of each operation type on a bucket for a given time period you can run a query as such

query R2VolumeExample(
	$accountTag: string!
	$startDate: Time
	$endDate: Time
	$bucketName: string
) {
	viewer {
		accounts(filter: { accountTag: $accountTag }) {
			r2OperationsAdaptiveGroups(
				limit: 10000
				filter: {
					datetime_geq: $startDate
					datetime_leq: $endDate
					bucketName: $bucketName
				}
			) {
				sum {
					requests
				}
				dimensions {
					actionType
				}
			}
		}
	}
}

The bucketName field can be removed to get an account level overview of operations. The volume of operations can be broken down even further by adding more dimensions to the query.

Storage

To query the storage of a bucket over a given time period you can run a query as such.

query R2StorageExample(
	$accountTag: string!
	$startDate: Time
	$endDate: Time
	$bucketName: string
) {
	viewer {
		accounts(filter: { accountTag: $accountTag }) {
			r2StorageAdaptiveGroups(
				limit: 10000
				filter: {
					datetime_geq: $startDate
					datetime_leq: $endDate
					bucketName: $bucketName
				}
				orderBy: [datetime_DESC]
			) {
				max {
					objectCount
					uploadCount
					payloadSize
					metadataSize
				}
				dimensions {
					datetime
				}
			}
		}
	}
}

Bandwidth

To query hourly uploads and downloads for a bucket. Results contain byte totals, not transfer rates, and excluded transfers smaller than 100 KiB.

query R2BandwidthExample(
	$accountTag: string!
	$startDate: Time!
	$endDate: Time!
	$bucketName: string!
) {
	viewer {
		accounts(filter: { accountTag: $accountTag }) {
			r2BandwidthUsageAdaptiveGroups(
				limit: 1000
				filter: {
					datetime_geq: $startDate
					datetime_lt: $endDate
					bucketName: $bucketName
				}
				orderBy: [datetimeHour_ASC]
			) {
				sum {
					bytesUpload
					bytesDownload
				}
				dimensions {
					datetimeHour
				}
			}
		}
	}
}

Release notes

import { ProductReleaseNotes } from "~/components";

{/* */}

Troubleshooting

import { FileTree } from "~/components";

Troubleshooting 403 / CORS issues with R2

If you are encountering a CORS error despite setting up everything correctly, you may follow this troubleshooting guide to help you.

If you see a 401/403 error above the CORS error in your browser console, you are dealing with a different issue (not CORS related).

If you do have a CORS issue, refer to Resolving CORS issues.

If you are using a custom domain

  1. Open developer tools on your browser.
  2. Go to the Network tab and find the failing request. You may need to reload the page, as requests are only logged after developer tools have been opened.
  3. Check the response headers for the following two headers:

If you have a cf-mitigated header

Your request was blocked by one of your WAF rules. Inspect your Security Events to identify the cause of the block.

If you do not have a cf-cache-status header

Your request was blocked by Hotlink Protection.

If you are using the S3 API

Your request may be incorrectly signed. You may obtain a better error message by trying the request over curl.

Refer to the working S3 signing examples on the Examples page.

If it is actually CORS

Here are some common issues with CORS configurations:

Object-level API tokens fail against the REST API

If you use an R2 API token created with the Object Read & Write or Object Read only permissions against the Cloudflare REST API (api.cloudflare.com), object requests fail to authenticate and return one of the following:

Object-level tokens are only supported by the S3-compatible API, which authenticates with AWS Signature Version 4 (SigV4).

To resolve this:

HTTP 5XX Errors and capacity limitations of Cloudflare R2

When you encounter an HTTP 5XX error, it is usually a sign that your Cloudflare R2 bucket has been overwhelmed by too many concurrent requests. These errors can trigger bucket-wide read and write locks, affecting the performance of all ongoing operations.

To avoid these disruptions, it is important to implement strategies for managing request volume.

Here are some mitigations you can employ:

Monitor concurrent requests

Track the number of concurrent requests to your bucket. If a client encounters a 5XX error, ensure that it retries the operation and communicates with other clients. By coordinating, clients can collectively slow down, reducing the request rate and maintaining a more stable flow of successful operations.

If your users are directly uploading to the bucket (for example, using the S3 or Workers API), you may not be able to monitor or enforce a concurrency limit. In that case, we recommend bucket sharding.

Bucket sharding

For higher capacity at the cost of added complexity, consider bucket sharding. This approach distributes reads and writes across multiple buckets, reducing the load on any single bucket. While sharding cannot prevent a single hot object from exhausting capacity, it can mitigate the overall impact and improve system resilience.

Objects named This object is unnamed

In the Cloudflare dashboard, you can choose to view objects with / in the name as folders by selecting View prefixes as directories.

For example, an object named example/object will be displayed as below.

- example - object

Object names which end with / will cause the Cloudflare dashboard to render the object as a folder with an unnamed object inside.

For example, uploading an object named example/ into an R2 bucket will be displayed as below.

- example - `This object is unnamed`

Consistency model

This page details R2's consistency model, including where R2 is strongly, globally consistent and which operations this applies to.

R2 can be described as "strongly consistent", especially in comparison to other distributed object storage systems. This strong consistency ensures that operations against R2 see the latest (accurate) state: clients should be able to observe the effects of any write, update and/or delete operation immediately, globally.

Terminology

In the context of R2, strong consistency and eventual consistency have the following meanings:

Operations and Consistency

Operations against R2 buckets and objects adhere to the following consistency guarantees:

Action Consistency
Read-after-write: Write (upload) an object, then read it Strongly consistent: readers will immediately see the latest object globally
Metadata: Update an object's metadata Strongly consistent: readers will immediately see the updated metadata globally
Deletion: Delete an object Strongly consistent: reads to that object will immediately return a "does not exist" error
Object listing: List the objects in a bucket Strongly consistent: the list operation will list all objects at that point in time
IAM: Adding/removing R2 Storage permissions Eventually consistent: A new or updated API key may take up to a minute to have permissions reflected globally

Additional notes:

Conditional writes

R2 evaluates conditional write headers (for example If-Match and If-None-Match on PutObject) atomically, as part of the same write that commits the object:

For S3 API usage, refer to Conditional operations in PutObject.

Negative reads and in-flight writes

A strongly consistent read (HeadObject or GetObject) that returns NoSuchKey (404) tells you that the object did not exist at the moment the read was processed. This absence is not final with respect to an earlier PutObject whose outcome is still unknown: a request that was still in flight — for example, a client that lost its connection before receiving the response — may still commit after the negative read has returned. Reads are never stale, but they do not wait for or cancel earlier writes.

If you need to treat a negative read as proof that a write failed, first make sure the original write has definitively completed (either succeeded or returned a definitive error) before trusting the read. Alternatively, retry the write with If-None-Match: *: it fails with 412 PreconditionFailed if the ambiguous write did in fact commit.

Caching

:::note

By default, Cloudflare's cache will cache common, cacheable status codes automatically per our cache documentation.

:::

When connecting a custom domain to an R2 bucket and enabling caching for objects served from that bucket, the consistency model is necessarily relaxed when accessing content via a domain with caching enabled.

Specifically, you should expect:

The cache does not affect access via Worker API bindings or the S3 API, as these operations are made directly against the bucket and do not transit through the cache.

Data location

import { WranglerConfig, DashButton } from "~/components";

Learn how the location of data stored in R2 is determined and about the different available inputs that control the physical location where objects in your buckets are stored.

Automatic (recommended)

When you create a new bucket, the data location is set to Automatic by default. Currently, this option chooses a bucket location in the closest available region to the create bucket request based on the location of the caller.

Location Hints

Location Hints are optional parameters you can provide during bucket creation to indicate the primary geographical location you expect data will be accessed from.

Using Location Hints can be a good choice when you expect the majority of access to data in a bucket to come from a different location than where the create bucket request originates. Keep in mind Location Hints are a best effort and not a guarantee, and they should only be used as a way to optimize performance by placing regularly updated content closer to users.

Set hints via the Cloudflare dashboard

You can choose to automatically create your bucket in the closest available region based on your location or choose a specific location from the list.

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select Create bucket.

  3. Enter a name for the bucket.

  4. Under Location, leave None selected for automatic selection or choose a region from the list.

  5. Select Create bucket to complete the bucket creation process.

Set hints via the S3 API

You can set the Location Hint via the LocationConstraint parameter using the S3 API:

await S3.send(
	new CreateBucketCommand({
		Bucket: "YOUR_BUCKET_NAME",
		CreateBucketConfiguration: {
			LocationConstraint: "WNAM",
		},
	}),
);

Refer to Examples for additional examples from other S3 SDKs.

Available hints

The following hint locations are supported:

Hint Hint description
wnam Western North America
enam Eastern North America
weur Western Europe
eeur Eastern Europe
apac Asia-Pacific
oc Oceania

Additional considerations

Location Hints are only honored the first time a bucket with a given name is created. If you delete and recreate a bucket with the same name, the original bucket’s location will be used.

Jurisdictional Restrictions

Jurisdictional Restrictions guarantee objects in a bucket are stored within a specific jurisdiction.

Use Jurisdictional Restrictions when you need to ensure data is stored and processed within a jurisdiction to meet data residency requirements, including local regulations such as the GDPR or FedRAMP.

Set jurisdiction via the Cloudflare dashboard

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select Create bucket.

  3. Enter a name for the bucket.

  4. Under Location, select Specify jurisdiction and choose a jurisdiction from the list.

  5. Select Create bucket to complete the bucket creation process.

Using jurisdictions from Workers

To access R2 buckets that belong to a jurisdiction from Workers, you will need to specify the jurisdiction as well as the bucket name as part of your bindings in your Wrangler configuration file:

{
	"r2_buckets": [
		{
			"binding": "MY_BUCKET",
			"bucket_name": "<YOUR_BUCKET_NAME>",
			"jurisdiction": "<JURISDICTION>"
		}
	]
}

For more information on getting started, refer to Use R2 from Workers.

Using jurisdictions with the S3 API

When interacting with R2 resources that belong to a defined jurisdiction with the S3 API or existing S3-compatible SDKs, you must specify the jurisdiction in your S3 endpoint:

https://<ACCOUNT_ID>.<JURISDICTION>.r2.cloudflarestorage.com

You can use your jurisdiction-specific endpoint for any supported S3 API operations. When using a jurisdiction endpoint, you will not be able to access R2 resources outside of that jurisdiction.

The example below shows how to create an R2 bucket in the eu jurisdiction using the @aws-sdk/client-s3 package for JavaScript.

import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";
const S3 = new S3Client({
	endpoint: "https://<account_id>.eu.r2.cloudflarestorage.com",
	credentials: {
		accessKeyId: "<access_key_id>",
		secretAccessKey: "<access_key_secret>",
	},
	region: "auto",
});
await S3.send(
	new CreateBucketCommand({
		Bucket: "YOUR_BUCKET_NAME",
	}),
);

Refer to Examples for additional examples from other S3 SDKs.

Available jurisdictions

The following jurisdictions are supported:

Jurisdiction Jurisdiction description
eu European Union
fedramp FedRAMP
us United States

:::note

Cloudflare Enterprise customers may contact their account team or Cloudflare Support to get access to the FedRAMP jurisdiction. :::

Limitations

The following services do not interact with R2 resources with assigned jurisdictions:

Additional considerations

Once an R2 bucket is created, the jurisdiction cannot be changed.

Data security

This page details the data security properties of R2, including encryption-at-rest (EAR), encryption-in-transit (EIT), and Cloudflare's compliance certifications.

Encryption at Rest

All objects stored in R2, including their metadata, are encrypted at rest. Encryption and decryption are automatic, do not require user configuration to enable, and do not impact the effective performance of R2.

Encryption keys are managed by Cloudflare and securely stored in the same key management systems we use for managing encrypted data across Cloudflare internally.

Objects are encrypted using AES-256, a widely tested, highly performant and industry-standard encryption algorithm. R2 uses GCM (Galois/Counter Mode) as its preferred mode.

Encryption in Transit

Data transfer between a client and R2 is secured using the same Transport Layer Security (TLS/SSL) supported on all Cloudflare domains.

Access over plaintext HTTP (without TLS/SSL) can be disabled by connecting a custom domain to your R2 bucket and enabling Always Use HTTPS.

:::note

R2 custom domains use Cloudflare for SaaS certificates and cannot be customized. Even if you have Advanced Certificate Manager, the advanced certificate will not be used due to certificate prioritization.

:::

Compliance

To learn more about Cloudflare's adherence to industry-standard security compliance certifications, visit the Cloudflare Trust Hub.

Durability

R2 is designed to provide 99.999999999% (eleven 9s) of annual durability. This means that if you store 10,000,000 objects on R2, you can expect to lose an object once every 10,000 years on average.

How R2 achieves eleven-nines durability

R2's durability is built on multiple layers of redundancy and data protection:

Considerations

Reference

import { DirectoryListing } from "~/components";

Unicode interoperability

R2 is built on top of Workers and supports Unicode natively. One nuance of Unicode that is often overlooked is the issue of filename interoperability due to Unicode equivalence.

Based on feedback from our users, we have chosen to NFC-normalize key names before storing by default. This means that Héllo and Héllo, for example, are the same object in R2 but different objects in other storage providers. Although Héllo and Héllo may be different character byte sequences, they are rendered the same.

R2 preserves the encoding for display though. When you list the objects, you will get back the last encoding you uploaded with.

There are still some platform-specific differences to consider:

If it is important for you to be able to bypass the unicode equivalence and use byte-oriented key names, contact your Cloudflare account team.

Wrangler commands

import { Render } from "~/components";

Snowflake

import { Render } from "~/components";

This page details which R2 location or jurisdiction is recommended based on your Snowflake region.

You have the following inputs to control the physical location where objects in your R2 buckets are stored (for more information refer to data location):

North and South America (Commercial)

Snowflake region name Cloud Region ID Recommended R2 location
Canada (Central) AWS ca-central-1 Location hint: enam
South America (Sao Paulo) AWS sa-east-1 Location hint: enam
US West (Oregon) AWS us-west-2 Jurisdiction: us or hint: wnam
US East (Ohio) AWS us-east-2 Jurisdiction: us or hint: enam
US East (N. Virginia) AWS us-east-1 Jurisdiction: us or hint: enam
US Central1 (Iowa) GCP us-central1 Jurisdiction: us or hint: enam
US East4 (N. Virginia) GCP us-east4 Jurisdiction: us or hint: enam
Canada Central (Toronto) Azure canadacentral Location hint: enam
Central US (Iowa) Azure centralus Jurisdiction: us or hint: enam
East US 2 (Virginia) Azure eastus2 Jurisdiction: us or hint: enam
Mexico Central (Mexico City) Azure mexicocentral Location hint: wnam
South Central US (Texas) Azure southcentralus Jurisdiction: us or hint: enam
West US 2 (Washington) Azure westus2 Jurisdiction: us or hint: wnam

U.S. Government

Snowflake region name Cloud Region ID Recommended R2 location
US Gov East 1 AWS us-gov-east-1 Jurisdiction: fedramp
US Gov West 1 AWS us-gov-west-1 Jurisdiction: fedramp
US Gov Virginia Azure usgovvirginia Jurisdiction: fedramp

:::note

Cloudflare Enterprise customers may contact their account team or Cloudflare Support to get access to the FedRAMP jurisdiction. :::

Europe and Middle East

Snowflake region name Cloud Region ID Recommended R2 location
EU (Frankfurt) AWS eu-central-1 Jurisdiction: eu or hint: weur/eeur
EU (Zurich) AWS eu-central-2 Jurisdiction: eu or hint: weur/eeur
EU (Stockholm) AWS eu-north-1 Jurisdiction: eu or hint: weur/eeur
EU (Ireland) AWS eu-west-1 Jurisdiction: eu or hint: weur/eeur
Europe (London) AWS eu-west-2 Jurisdiction: eu or hint: weur/eeur
EU (Paris) AWS eu-west-3 Jurisdiction: eu or hint: weur/eeur
Middle East Central2 (Dammam) GCP me-central2 Location hint: weur/eeur
Europe West2 (London) GCP europe-west-2 Jurisdiction: eu or hint: weur/eeur
Europe West3 (Frankfurt) GCP europe-west-3 Jurisdiction: eu or hint: weur/eeur
Europe West4 (Netherlands) GCP europe-west-4 Jurisdiction: eu or hint: weur/eeur
North Europe (Ireland) Azure northeurope Jurisdiction: eu or hint: weur/eeur
Switzerland North (Zurich) Azure switzerlandnorth Jurisdiction: eu or hint: weur/eeur
West Europe (Netherlands) Azure westeurope Jurisdiction: eu or hint: weur/eeur
UAE North (Dubai) Azure uaenorth Location hint: weur/eeur
UK South (London) Azure uksouth Jurisdiction: eu or hint: weur/eeur

Asia Pacific and China

Snowflake region name Cloud Region ID Recommended R2 location
Asia Pacific (Tokyo) AWS ap-northeast-1 Location hint: apac
Asia Pacific (Seoul) AWS ap-northeast-2 Location hint: apac
Asia Pacific (Osaka) AWS ap-northeast-3 Location hint: apac
Asia Pacific (Mumbai) AWS ap-south-1 Location hint: apac
Asia Pacific (Singapore) AWS ap-southeast-1 Location hint: apac
Asia Pacific (Sydney) AWS ap-southeast-2 Location hint: oc
Asia Pacific (Jakarta) AWS ap-southeast-3 Location hint: apac
China (Ningxia) AWS cn-northwest-1 Location hint: apac
Australia East (New South Wales) Azure australiaeast Location hint: oc
Central India (Pune) Azure centralindia Location hint: apac
Japan East (Tokyo) Azure japaneast Location hint: apac
Southeast Asia (Singapore) Azure southeastasia Location hint: apac

Protect an R2 Bucket with Cloudflare Access

import { Render } from "~/components";

You can secure access to R2 buckets using Cloudflare Access.

Access allows you to only allow specific users, groups or applications within your organization to access objects within a bucket, or specific sub-paths, based on policies you define.

:::note

For providing secure access to bucket objects for anonymous users, we recommend using pre-signed URLs instead.

Pre-signed URLs do not require users to be a member of your organization and enable programmatic application directly.

:::

1. Create a bucket

If you have an existing R2 bucket, you can skip this step.

You will need to create an R2 bucket. Follow the R2 get started guide to create a bucket before returning to this guide.

2. Create an Access application

Within the Zero Trust section of the Cloudflare Dashboard, you will need to create an Access application and a policy to restrict access to your R2 bucket.

If you have not configured Cloudflare Access before, we recommend:

To create an Access application for your R2 bucket:

  1. In the Cloudflare dashboard, go to Zero Trust > Access controls > Applications and select Create new application.

  2. Select Self-hosted and private.

  3. Select Add public hostname and enter the application domain. The Domain must be a domain hosted on Cloudflare, and the Subdomain part of the custom domain you will connect to your R2 bucket. For example, if you want to serve files from behind-access.example.com and example.com is a domain within your Cloudflare account, then enter behind-access in the subdomain field and select example.com from the Domain list.

  4. Add Access policies to control who can connect to your application. This should be an Allow policy so that users can access objects within the bucket behind this Access application.

    :::note Ensure that your policies only allow the users within your organization that need access to this R2 bucket. :::

  5. Follow the remaining self-hosted application creation steps to publish the application.

3. Connect a custom domain

:::caution

You should create an Access application before connecting a custom domain to your bucket, as connecting a custom domain will otherwise make your bucket public by default.

:::

You will need to connect a custom domain to your bucket in order to configure it as an Access application. Make sure the custom domain is the same domain you entered when configuring your Access policy.

4. Test your Access policy

Visit the custom domain you connected to your R2 bucket, which should present a Cloudflare Access authentication page with your selected identity provider(s) and/or authentication methods.

For example, if you connected Google and/or GitHub identity providers, you can log in with those providers. If the login is successful and you pass the Access policies configured in this guide, you will be able to access (read/download) objects within the R2 bucket.

If you cannot authenticate or receive a block page after authenticating, check that you have an Access policy configured within your Access application that explicitly allows the group your user account is associated with.

Next steps

Tutorials

import { GlossaryTooltip, ListTutorials } from "~/components";

View tutorials to help you get started with R2.

Mastodon

import { DashButton } from "~/components";

Mastodon is a popular fediverse software. This guide will explain how to configure R2 to be the object storage for a self hosted Mastodon instance, for either a new instance or an existing instance.

Set up a new instance

You can set up a self hosted Mastodon instance in multiple ways. Refer to the official documentation for more details. When you reach the Configuring your environment step in the Mastodon documentation after installation, refer to the procedures below for the next steps.

1. Determine the hostname to access files

Different from the default hostname of your Mastodon instance, object storage for files requires a unique hostname. As an example, if you set up your Mastodon's hostname to be mastodon.example.com, you can use mastodon-files.example.com or files.example.com for accessing files. This means that when visiting your instance on mastodon.example.com, whenever there are media attached to a post such as an image or a video, the file will be served under the hostname determined at this step, such as mastodon-files.example.com.

:::note

If you move from R2 to another S3 compatible service later on, you can continue using the same hostname determined in this step. We do not recommend changing the hostname after the instance has been running to avoid breaking historical file references. In such a scenario, Bulk Redirects can be used to instruct requests reaching the previous hostname to refer to the new hostname.

:::

2. Create and set up an R2 bucket

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. Select Create bucket.

  3. Enter your bucket name and then select Create bucket. This name is internal when setting up your Mastodon instance and is not publicly accessible.

  4. Once the bucket is created, navigate to the Settings tab of this bucket and copy the value of S3 API.

  5. From the Settings tab, select Connect Domain and enter the hostname from step 1.

  6. Navigate back to the R2's overview page and select Manage R2 API Tokens.

  7. Select Create API token.

  8. Name your token Mastodon by selecting the pencil icon next to the API name and grant it the Edit permission. Select Create API Token to finalize token creation.

  9. Copy the values of Access Key ID and Secret Access Key.

3. Configure R2 for Mastodon

While configuring your Mastodon instance based on the official configuration file, replace the File storage section with the following details.

S3_ENABLED=true
S3_ALIAS_HOST={{mastodon-files.example.com}}                  # Change to the hostname determined in step 1
S3_BUCKET={{your-bucket-name}}                                # Change to the bucket name set in step 2
S3_ENDPOINT=https://{{unique-id}}.r2.cloudflarestorage.com/   # Change the {{unique-id}} to the part of S3 API retrieved in step 2
AWS_ACCESS_KEY_ID={{your-access-key-id}}                      # Change to the Access Key ID retrieved in step 2
AWS_SECRET_ACCESS_KEY={{your-secret-access-key}}              # Change to the Secret Access Key retrieved in step 2
S3_PROTOCOL=https
S3_PERMISSION=

Leave S3_PERMISSION empty. This prevents Mastodon from sending ACL headers, which R2 does not support.

After configuration, you can run your instance. After the instance is running, upload a media attachment and verify the attachment is retrieved from the hostname set above. When navigating back to the bucket's page in R2, you should see the following structure.

Mastodon bucket structure after instance is set up and running

Migrate to R2

If you already have an instance running, you can migrate the media files to R2 and benefit from no egress cost.

1. Set up an R2 bucket and start file migration

  1. (Optional) To minimize the number of migrated files, you can use the Mastodon admin CLI to clean up unused files.
  2. Set up an R2 bucket ready for file migration by following steps 1 and 2 from Setting up a new instance section above.
  3. Migrate all the media files to R2. Refer to the examples provided to connect various providers together. If you currently host these media files locally, you can use rclone to upload these local files to R2.

2. (Optional) Set up file path redirects

While the file migration is in progress, which may take a while, you can prepare file path redirect settings.

If you had the media files hosted locally, you will likely need to set up redirects. By default, media files hosted locally would have a path similar to https://mastodon.example.com/cache/..., which needs to be redirected to a path similar to https://mastodon-files.example.com/cache/... after the R2 bucket is up and running alongside your Mastodon instance. If you already use another S3 compatible object storage service and would like to keep the same hostname, you do not need to set up redirects.

Bulk Redirects are available for all plans. Refer to Create Bulk Redirects in the dashboard for more information.

List of Source URLs and their new Target URLs as part of Bulk Redirects

3. Verify bucket and redirects

Depending on your migration plan, you can verify if the bucket is accessible publicly and the redirects work correctly. To verify, open an existing uploaded media file with a path like https://mastodon.example.com/cache/... and replace the hostname from mastodon.example.com to mastodon-files.example.com and visit the new path. If the file opened correctly, proceed to the final step.

4. Finalize migration

Your instance may be still running during migration, and during migration, you likely have new media files created either through direct uploads or fetched from other federated instances. To upload only the newly created files, you can use a program like rclone. Note that when re-running the sync program, all existing files will be checked using at least Class B operations.

Once all the files are synced, you can restart your Mastodon instance with the new object storage configuration as mentioned in step 3 of Set up a new instance.

Postman

import { DashButton } from "~/components";

Postman is an API platform that makes interacting with APIs easier. This guide will explain how to use Postman to make authenticated R2 requests to create a bucket, upload a new object, and then retrieve the object. The R2 Postman collection includes a complete list of operations supported by the platform.

1. Purchase R2

This guide assumes that you have made a Cloudflare account and purchased R2.

2. Explore R2 in Postman

Explore R2's publicly available Postman collection. The collection is organized into a Buckets folder for bucket-level operations and an Objects folder for object-level operations. Operations in the Objects > Upload folder allow for adding new objects to R2.

3. Configure your R2 credentials

In the Postman dashboard, select the Cloudflare R2 collection and navigate to the Variables tab. In Variables, you can set variables within the R2 collection. They will be used to authenticate and interact with the R2 platform. Remember to always select Save after updating a variable.

To execute basic operations, you must set the account-id, r2-access-key-id, and r2-secret-access-key variables in the Postman dashboard > Variables.

To do this:

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. In R2, under Manage R2 API Tokens on the right side of the dashboard, copy your Cloudflare account ID.

  3. Go back to the Postman dashboard.

  4. Set the CURRENT VALUE of account-id to your Cloudflare account ID and select Save.

Next, generate an R2 API token:

  1. In the Cloudflare dashboard, go to the R2 object storage page.

  2. On the right hand sidebar, select Manage R2 API Tokens.

  3. Select Create API token.

  4. Name your token Postman by selecting the pencil icon next to the API name and grant it the Edit permission.

Guard this token and the Access Key ID and Secret Access Key closely. You will not be able to review these values again after finishing this step. Anyone with this information can fully interact with all of your buckets.

After you have created your API token in the Cloudflare dashboard:

  1. Go to the Postman dashboard > Variables.
  2. Copy Access Key ID value from the Cloudflare dashboard and paste it into Postman’s r2-access-key-id variable value and select Save.
  3. Copy the Secret Access Key value from the Cloudflare dashboard and paste it into Postman’s r2-secret-access-key variable value and select Save.

By now, you should have account-id, r2-secret-access-key, and r2-access-key-id set in Postman.

To verify the token:

  1. In the Postman dashboard, select the Cloudflare R2 folder dropdown arrow > Buckets folder dropdown arrow > GETListBuckets.
  2. Select Send.

The Postman collection uses AWS SigV4 authentication to complete the handshake.

You should see a 200 OK response with a list of existing buckets. If you receive an error, ensure your R2 subscription is active and Postman variables are saved correctly.

4. Create a bucket

In the Postman dashboard:

  1. Go to Variables.
  2. Set the r2-bucket variable value as the name of your R2 bucket and select Save.
  3. Select the Cloudflare R2 folder dropdown arrow > Buckets folder dropdown arrow > PUTCreateBucket and select Send.

You should see a 200 OK response. If you run the ListBuckets request again, your bucket will appear in the list of results.

5. Add an object

You will now add an object to your bucket:

  1. Go to Variables in the Postman dashboard.
  2. Set r2-object to cat-pic.jpg and select Save.
  3. Select Cloudflare R2 folder dropdown arrow > Objects folder dropdown arrow > Multipart folder dropdown arrow > PUTPutObject and select Send.
  4. Go to Body and choose binary before attaching your cat picture.
  5. Select Send to add the cat picture to your R2 bucket.

After a few seconds, you should receive a 200 OK response.

6. Get an object

It only takes a few more more clicks to download our cat friend using the GetObject request.

  1. Select the Cloudflare R2 folder dropdown arrow > Objects folder dropdown arrow > GETGetObject.
  2. Select Send.

The R2 team will keep this collection up to date as we expand R2 features set. You can explore the rest of the R2 Postman collection by experimenting with other operations.

Use event notification to summarize PDF files on upload

import { Render, PackageManagers, Details, WranglerConfig } from "~/components";

In this tutorial, you will learn how to use event notifications to process a PDF file when it is uploaded to an R2 bucket. You will use Workers AI to summarize the PDF and store the summary as a text file in the same bucket.

Prerequisites

To continue, you will need:

For supported Node.js versions, refer to the
[Wrangler system requirements](/workers/wrangler/install-and-update/#install-wrangler).

1. Create a new project

You will create a new Worker project that will use Static Assets to serve the front-end of your application. A user can upload a PDF file using this front-end, which will then be processed by your Worker.

Create a new Worker project by running the following commands:

<PackageManagers type="create" pkg="cloudflare@latest" args={"pdf-summarizer"} />

<Render
	file="c3-post-run-steps-hello-world"
	product="workers"
	params={{
	type: "Worker only",
	lang: "TypeScript",
	}}
/>

Navigate to the pdf-summarizer directory:

cd pdf-summarizer

2. Create the front-end

Using Static Assets, you can serve the front-end of your application from your Worker. To use Static Assets, you need to add the required bindings to your Wrangler file.

{
	"assets": {
		"directory": "public"
	}
}

Next, create a public directory and add an index.html file. The index.html file should contain the following HTML code:

Select to view the HTML code
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>PDF Summarizer</title>
		<style>
			body {
				font-family: Arial, sans-serif;
				display: flex;
				flex-direction: column;
				min-height: 100vh;
				margin: 0;
				background-color: #fefefe;
			}
			.content {
				flex: 1;
				display: flex;
				justify-content: center;
				align-items: center;
			}
			.upload-container {
				background-color: #f0f0f0;
				padding: 20px;
				border-radius: 8px;
				box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
			}
			.upload-button {
				background-color: #4caf50;
				color: white;
				padding: 10px 15px;
				border: none;
				border-radius: 4px;
				cursor: pointer;
				font-size: 16px;
			}
			.upload-button:hover {
				background-color: #45a049;
			}
			footer {
				background-color: #f0f0f0;
				color: white;
				text-align: center;
				padding: 10px;
				width: 100%;
			}
			footer a {
				color: #333;
				text-decoration: none;
				margin: 0 10px;
			}
			footer a:hover {
				text-decoration: underline;
			}
		</style>
	</head>
	<body>
		<div class="content">
			<div class="upload-container">
				<h2>Upload PDF File</h2>
				<form id="uploadForm" onsubmit="return handleSubmit(event)">
					<input
						type="file"
						id="pdfFile"
						name="pdfFile"
						accept=".pdf"
						required
					/>
					<button type="submit" id="uploadButton" class="upload-button">
						Upload
					</button>
				</form>
			</div>
		</div>

		<footer>
			<a
				href="https://developers.cloudflare.com/r2/buckets/event-notifications/"
				target="_blank"
				>R2 Event Notification</a
			>
			<a
				href="https://developers.cloudflare.com/queues/get-started/#3-create-a-queue"
				target="_blank"
				>Cloudflare Queues</a
			>
			<a href="https://developers.cloudflare.com/workers-ai/" target="_blank"
				>Workers AI</a
			>
			<a
				href="https://github.com/harshil1712/pdf-summarizer-r2-event-notification"
				target="_blank"
				>GitHub Repo</a
			>
		</footer>

		<script>
			handleSubmit = async (event) => {
				event.preventDefault();

				// Disable the upload button and show a loading message
				const uploadButton = document.getElementById("uploadButton");
				uploadButton.disabled = true;
				uploadButton.textContent = "Uploading...";

				// get form data
				const formData = new FormData(event.target);
				const file = formData.get("pdfFile");

				if (file) {
					// call /api/upload endpoint and send the file
					await fetch("/api/upload", {
						method: "POST",
						body: formData,
					});

					event.target.reset();
				} else {
					console.log("No file selected");
				}
				uploadButton.disabled = false;
				uploadButton.textContent = "Upload";
			};
		</script>
	</body>
</html>

To view the front-end of your application, run the following command and navigate to the URL displayed in the terminal:

npm run dev
 ⛅️ wrangler 3.80.2
-------------------

⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787
╭───────────────────────────╮
│  [b] open a browser       │
│  [d] open devtools        │
│  [l] turn off local mode  │
│  [c] clear console        │
│  [x] to exit              │
╰───────────────────────────╯

When you open the URL in your browser, you will see that there is a file upload form. If you try uploading a file, you will notice that the file is not uploaded to the server. This is because the front-end is not connected to the back-end. In the next step, you will update your Worker that will handle the file upload.

3. Handle file upload

To handle the file upload, you will first need to add the R2 binding. In the Wrangler file, add the following code:

{
	"r2_buckets": [
		{
			"binding": "MY_BUCKET",
			"bucket_name": "<R2_BUCKET_NAME>"
		}
	]
}

Replace <R2_BUCKET_NAME> with the name of your R2 bucket.

Next, update the src/index.ts file. The src/index.ts file should contain the following code:

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// Get the pathname from the request
		const pathname = new URL(request.url).pathname;

		if (pathname === "/api/upload" && request.method === "POST") {
			// Get the file from the request
			const formData = await request.formData();
			const file = formData.get("pdfFile") as File;

			// Upload the file to Cloudflare R2
			const upload = await env.MY_BUCKET.put(file.name, file);
			return new Response("File uploaded successfully", { status: 200 });
		}

		return new Response("incorrect route", { status: 404 });
	},
} satisfies ExportedHandler<Env>;

The above code does the following:

Since the Worker code is written in TypeScript, you should run the following command to add the necessary type definitions. While this is not required, it will help you avoid errors.

npm run cf-typegen

You can restart the developer server to test the changes:

npm run dev

4. Create a queue

Event notifications capture changes to data in your R2 bucket. You will need to create a new queue pdf-summarize to receive notifications:

npx wrangler queues create pdf-summarizer

Add the binding to the Wrangler file:

{
	"queues": {
		"consumers": [
			{
				"queue": "pdf-summarizer"
			}
		]
	}
}

5. Handle event notifications

Now that you have a queue to receive event notifications, you need to update the Worker to handle the event notifications. You will need to add a Queue handler that will extract the textual content from the PDF, use Workers AI to summarize the content, and then save it in the R2 bucket.

Update the src/index.ts file to add the Queue handler:

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// No changes in the fetch handler
	},
	async queue(batch, env) {
		for (let message of batch.messages) {
			console.log(`Processing the file: ${message.body.object.key}`);
		}
	},
} satisfies ExportedHandler<Env>;

The above code does the following:

For now the queue handler is not doing anything. In the next steps, you will update the queue handler to extract the textual content from the PDF, use Workers AI to summarize the content, and then add it to the bucket.

6. Extract the textual content from the PDF

To extract the textual content from the PDF, the Worker will use the unpdf library. The unpdf library provides utilities to work with PDF files.

Install the unpdf library by running the following command:

Update the src/index.ts file to import the required modules from the unpdf library:

import { extractText, getDocumentProxy } from "unpdf";

Next, update the queue handler to extract the textual content from the PDF:

async queue(batch, env) {
  for(let message of batch.messages) {
    console.log(`Processing file: ${message.body.object.key}`);
    // Get the file from the R2 bucket
    const file = await env.MY_BUCKET.get(message.body.object.key);
    if (!file) {
				console.error(`File not found: ${message.body.object.key}`);
				continue;
			}
    // Extract the textual content from the PDF
    const buffer = await file.arrayBuffer();
    const document = await getDocumentProxy(new Uint8Array(buffer));

    const {text} = await extractText(document, {mergePages: true});
    console.log(`Extracted text: ${text.substring(0, 100)}...`);
    }
}

The above code does the following:

7. Use Workers AI to summarize the content

To use Workers AI, you will need to add the Workers AI binding to the Wrangler file. The Wrangler file should contain the following code:

{
	"ai": {
		"binding": "AI"
	}
}

Execute the following command to add the AI type definition:

npm run cf-typegen

Update the src/index.ts file to use Workers AI to summarize the content:

async queue(batch, env) {
  for(let message of batch.messages) {
    // Extract the textual content from the PDF
    const {text} = await extractText(document, {mergePages: true});
    console.log(`Extracted text: ${text.substring(0, 100)}...`);

    // Use Workers AI to summarize the content
    const result: AiSummarizationOutput = await env.AI.run(
    "@cf/facebook/bart-large-cnn",
      {
        input_text: text,
      }
    );
    const summary = result.summary;
    console.log(`Summary: ${summary.substring(0, 100)}...`);
  }
}

The queue handler now uses Workers AI to summarize the content.

8. Add the summary to the R2 bucket

Now that you have the summary, you need to add it to the R2 bucket. Update the src/index.ts file to add the summary to the R2 bucket:

async queue(batch, env) {
  for(let message of batch.messages) {
    // Extract the textual content from the PDF
    // ...
    // Use Workers AI to summarize the content
    // ...

    // Add the summary to the R2 bucket
    const upload = await env.MY_BUCKET.put(`${message.body.object.key}-summary.txt`, summary, {
					httpMetadata: {
						contentType: 'text/plain',
					},
		});
		console.log(`Summary added to the R2 bucket: ${upload.key}`);
  }
}

The queue handler now adds the summary to the R2 bucket as a text file.

9. Enable event notifications

Your queue handler is ready to handle incoming event notification messages. You need to enable event notifications with the wrangler r2 bucket notification create command for your bucket. The following command creates an event notification for the object-create event type for the pdf suffix:

npx wrangler r2 bucket notification create <R2_BUCKET_NAME> --event-type object-create --queue pdf-summarizer --suffix "pdf"

Replace <R2_BUCKET_NAME> with the name of your R2 bucket.

An event notification is created for the pdf suffix. When a new file with the pdf suffix is uploaded to the R2 bucket, the pdf-summarizer queue is triggered.

10. Deploy your Worker

To deploy your Worker, run the wrangler deploy command:

npx wrangler deploy

In the output of the wrangler deploy command, copy the URL. This is the URL of your deployed application.

11. Test

To test the application, navigate to the URL of your deployed application and upload a PDF file. Alternatively, you can use the Cloudflare dashboard to upload a PDF file.

To view the logs, you can use the wrangler tail command.

npx wrangler tail

You will see the logs in your terminal. You can also navigate to the Cloudflare dashboard and view the logs in the Workers Logs section.

If you check your R2 bucket, you will see the summary file.

Conclusion

In this tutorial, you learned how to use R2 event notifications to process an object on upload. You created an application to upload a PDF file, and created a consumer Worker that creates a summary of the PDF file. You also learned how to use Workers AI to summarize the content of the PDF file, and upload the summary to the R2 bucket.

You can use the same approach to process other types of files, such as images, videos, and audio files. You can also use the same approach to process other types of events, such as object deletion, and object update.

If you want to view the code for this tutorial, you can find it on GitHub.

Log and store upload events in R2 with event notifications

import { Render, PackageManagers, WranglerConfig } from "~/components";

This example provides a step-by-step guide on using event notifications to capture and store R2 upload logs in a separate bucket.

Push-Based R2 Event Notifications

1. Install Wrangler

To begin, refer to Install/Update Wrangler to install Wrangler, the Cloudflare Developer Platform CLI.

2. Create R2 buckets

You will need to create two R2 buckets:

To create the buckets, run the following Wrangler commands:

npx wrangler r2 bucket create example-upload-bucket
npx wrangler r2 bucket create example-log-sink-bucket

3. Create a queue

Event notifications capture changes to data in example-upload-bucket. You will need to create a new queue to receive notifications:

npx wrangler queues create example-event-notification-queue

4. Create a Worker

Before you enable event notifications for example-upload-bucket, you need to create a consumer Worker to receive the notifications.

Create a new Worker with C3 (create-cloudflare CLI). C3 is a command-line tool designed to help you set up and deploy new applications, including Workers, to Cloudflare.

<PackageManagers type="create" pkg="cloudflare@latest" args={"consumer-worker"} />

<Render file="c3-post-run-steps-hello-world" product="workers" params={{ type: "Worker only", lang: "TypeScript", }} />

Then, move into your newly created directory:

cd consumer-worker

5. Configure your Worker

In your Worker project's [Wrangler configuration file](/workers/wrangler/configuration/), add a queue consumer and R2 bucket binding. The queues consumer bindings will register your Worker as a consumer of your future event notifications and the R2 bucket bindings will allow your Worker to access your R2 bucket.

{
	"$schema": "./node_modules/wrangler/config-schema.json",
	"name": "event-notification-writer",
	"main": "src/index.ts",
	"upload_source_maps": true,
	"compatibility_date": "$today",
	"compatibility_flags": [
		"nodejs_compat"
	],
	"queues": {
		"consumers": [
			{
				"queue": "example-event-notification-queue",
				"max_batch_size": 100,
				"max_batch_timeout": 5
			}
		]
	},
	"r2_buckets": [
		{
			"binding": "LOG_SINK",
			"bucket_name": "example-log-sink-bucket"
		}
	]
}

6. Write event notification messages to R2

Add a queue handler to src/index.ts to handle writing batches of notifications to our log sink bucket (you do not need a fetch handler):

export interface Env {
	LOG_SINK: R2Bucket;
}

export default {
	async queue(batch, env): Promise<void> {
		const batchId = new Date().toISOString().replace(/[:.]/g, "-");
		const fileName = `upload-logs-${batchId}.json`;

		// Serialize the entire batch of messages to JSON
		const fileContent = new TextEncoder().encode(
			JSON.stringify(batch.messages),
		);

		// Write the batch of messages to R2
		await env.LOG_SINK.put(fileName, fileContent, {
			httpMetadata: {
				contentType: "application/json",
			},
		});
	},
} satisfies ExportedHandler<Env>;

7. Deploy your Worker

To deploy your consumer Worker, run the wrangler deploy command:

npx wrangler deploy

8. Enable event notifications

Now that you have your consumer Worker ready to handle incoming event notification messages, you need to enable event notifications with the wrangler r2 bucket notification create command for example-upload-bucket:

npx wrangler r2 bucket notification create example-upload-bucket --event-type object-create --queue example-event-notification-queue

9. Test

Now you can test the full end-to-end flow by uploading an object to example-upload-bucket in the Cloudflare dashboard. After you have uploaded an object, logs will appear in example-log-sink-bucket in a few seconds.