Table of Contents

R2 Object Storage

The Cloudflare.NET.R2 package provides a high-level client for Cloudflare R2 object storage using the S3-compatible API.

Installation

dotnet add package Cloudflare.NET.R2

Configuration

{
  "Cloudflare": {
    "AccountId": "your-cloudflare-account-id"
  },
  "R2": {
    "AccessKeyId": "your-r2-access-key-id",
    "SecretAccessKey": "your-r2-secret-access-key"
  }
}
builder.Services.AddCloudflareR2Client(builder.Configuration);

Jurisdiction Support

R2 supports jurisdictional restrictions that ensure data stays within specific geographic regions. The SDK provides full support for jurisdictions through configuration and the IR2ClientFactory.

Available Jurisdictions

Jurisdiction Description Endpoint
Default No restriction (global) https://{account_id}.r2.cloudflarestorage.com
EuropeanUnion EU data residency https://{account_id}.eu.r2.cloudflarestorage.com
FedRamp FedRAMP compliance https://{account_id}.fedramp.r2.cloudflarestorage.com
UnitedStates US data residency https://{account_id}.us.r2.cloudflarestorage.com

Configuration-Based Jurisdiction

Set the jurisdiction in your configuration to use it as the default:

{
  "R2": {
    "AccessKeyId": "...",
    "SecretAccessKey": "...",
    "Jurisdiction": "eu"
  }
}

Runtime Jurisdiction with IR2ClientFactory

Use IR2ClientFactory to create clients for different jurisdictions at runtime:

public class MultiJurisdictionService(IR2ClientFactory factory)
{
    // Get client for specific jurisdiction using default credentials
    public async Task UploadToEuAsync(string bucket, string key, Stream data)
    {
        var euClient = factory.GetClient(R2Jurisdiction.EuropeanUnion);
        await euClient.UploadAsync(bucket, key, data);
    }

    // Get client for specific jurisdiction using named credentials
    public async Task UploadToProdEuAsync(string bucket, string key, Stream data)
    {
        var client = factory.GetClient("production", R2Jurisdiction.EuropeanUnion);
        await client.UploadAsync(bucket, key, data);
    }
}
Note

The same R2 credentials work across all jurisdictions within an account, only the S3 endpoint differs. Clients are cached by (name, jurisdiction) tuple and reused.

Named Clients

For multi-account scenarios, register named R2 clients:

// Registration
services.AddCloudflareR2Client("production", config);
services.AddCloudflareR2Client("staging", config);

// Usage
public class StorageService(IR2ClientFactory factory)
{
    public IR2Client Production => factory.GetClient("production");
    public IR2Client Staging => factory.GetClient("staging");
}

Basic Operations

Upload Objects

public class StorageService(IR2Client r2)
{
    // Upload a file (auto-selects single or multipart based on size)
    public async Task<R2Result> UploadFileAsync(string bucket, string key, string filePath)
    {
        return await r2.UploadAsync(bucket, key, filePath);
    }

    // Upload from stream
    public async Task<R2Result> UploadStreamAsync(string bucket, string key, Stream stream)
    {
        return await r2.UploadAsync(bucket, key, stream);
    }

    // Upload as a multipart upload, recording the content type the finished object will carry.
    // The upload methods above do not set a content type, so R2 stores those objects as
    // application/octet-stream. Setting it requires the multipart route or a presigned PUT URL.
    public async Task<R2Result> UploadWithContentTypeAsync(
        string bucket,
        string key,
        string filePath,
        string contentType)
    {
        var initiate = await r2.InitiateMultipartUploadAsync(bucket, key, contentType);
        // Upload each part, then complete. See the multipart article for the full sequence.
        return initiate.Metrics;
    }
}
Note

UploadAsync, UploadSinglePartAsync and UploadMultipartAsync do not accept a content type, so objects they write carry R2's default of application/octet-stream. Use InitiateMultipartUploadAsync with a content type, or a presigned PUT URL whose PresignedPutRequest.ContentType names the type, when the stored value matters.

Download Objects

// Download to file
public async Task DownloadToFileAsync(string bucket, string key, string filePath)
{
    await r2.DownloadFileAsync(bucket, key, filePath);
}

// Download to stream
public async Task<Stream> DownloadToStreamAsync(string bucket, string key)
{
    var memoryStream = new MemoryStream();
    await r2.DownloadFileAsync(bucket, key, memoryStream);
    memoryStream.Position = 0;
    return memoryStream;
}

Delete Objects

// Delete single object (free operation)
public async Task DeleteObjectAsync(string bucket, string key)
{
    await r2.DeleteObjectAsync(bucket, key);
}

// Batch delete (up to 1000 keys per request)
public async Task DeleteObjectsAsync(string bucket, IEnumerable<string> keys)
{
    await r2.DeleteObjectsAsync(bucket, keys);
}

// Clear entire bucket: deletes every object, then aborts every multipart
// upload left open, since R2 will not delete a bucket while one is open.
public async Task ClearBucketAsync(string bucket)
{
    await r2.ClearBucketAsync(bucket);
}

List Objects

// Walk every page and return the complete set of objects
public async Task<IReadOnlyList<S3Object>> ListAllAsync(string bucket, string? prefix = null)
{
    var result = await r2.ListObjectsAsync(bucket, prefix);
    return result.Data;
}

// Read one page at a time when the bucket is too large to hold in memory
public async Task ProcessPageByPageAsync(string bucket, string? prefix)
{
    string? token = null;

    do
    {
        var page = await r2.ListObjectsPageAsync(bucket, prefix, 1000, token);

        foreach (var obj in page.Data.Objects)
        {
            await ProcessAsync(obj);
        }

        token = page.Data.NextContinuationToken;
    }
    while (token is not null);
}

// Find multipart uploads that were started and never completed.
// Their parts occupy storage that object listing cannot show you.
public async Task<IReadOnlyList<MultipartUpload>> FindOpenUploadsAsync(string bucket)
{
    var result = await r2.ListMultipartUploadsAsync(bucket, null);
    return result.Data;
}

Presigned URLs

Generate presigned URLs so clients can transfer bytes directly to and from R2:

// Generate presigned PUT URL for a direct upload
public string GetPresignedUploadUrl(string bucket, string key, long size, string contentType)
{
    return r2.CreatePresignedPutUrl(bucket, new PresignedPutRequest(
        Key: key,
        ExpiresAfter: TimeSpan.FromHours(1),
        ContentLength: size,
        ContentType: contentType));
}

// Generate presigned GET URL for a direct download,
// naming the file the recipient's browser will save
public string GetPresignedDownloadUrl(string bucket, string key, string downloadName)
{
    return r2.CreatePresignedGetUrl(bucket, new PresignedGetRequest(
        Key: key,
        ExpiresAfter: TimeSpan.FromMinutes(15),
        ResponseContentDisposition: $"attachment; filename=\"{downloadName}\""));
}

// Generate presigned URL for one part of a multipart upload
public string GetPresignedPartUrl(
    string bucket, string key, string uploadId, int partNumber, long partSize)
{
    return r2.CreatePresignedUploadPartUrl(bucket, new PresignedUploadPartRequest(
        Key: key,
        UploadId: uploadId,
        PartNumber: partNumber,
        ExpiresAfter: TimeSpan.FromHours(1),
        ContentLength: partSize,
        ContentType: "application/octet-stream"));
}

Multipart Uploads

For large files (> 5 GiB), use multipart uploads:

// Automatic multipart upload based on size
public async Task<R2Result> UploadLargeFileAsync(string bucket, string key, string filePath)
{
    // Automatically uses multipart for files > threshold
    return await r2.UploadAsync(bucket, key, filePath);
}

// Explicit multipart upload
public async Task<R2Result> UploadMultipartAsync(string bucket, string key, Stream stream)
{
    return await r2.UploadMultipartAsync(bucket, key, stream);
}

Operation Metrics

All R2 operations return R2Result or R2Result<T> which includes operation metrics:

var result = await r2.UploadAsync(bucket, key, stream);

Console.WriteLine($"Class A Operations: {result.ClassAOperations}");
Console.WriteLine($"Class B Operations: {result.ClassBOperations}");
Console.WriteLine($"Ingress Bytes: {result.IngressBytes}");
Console.WriteLine($"Egress Bytes: {result.EgressBytes}");

Error Handling

The R2 client provides specialized exceptions:

try
{
    await r2.UploadAsync(bucket, key, stream);
}
catch (CloudflareR2OperationException ex)
{
    // Single operation failure
    Console.WriteLine($"Operation failed: {ex.Message}");
    Console.WriteLine($"Partial metrics: {ex.PartialMetrics}");
}
catch (CloudflareR2BatchException<string> ex)
{
    // Batch operation failure
    foreach (var failedKey in ex.FailedItems)
    {
        Console.WriteLine($"Failed to delete: {failedKey}");
    }
}
catch (CloudflareR2ConfigurationException ex)
{
    // Missing or invalid configuration
    Console.WriteLine($"Configuration error: {ex.Message}");
}

Bucket Management

Bucket operations are available through the REST API client:

public class BucketService(ICloudflareApiClient cf)
{
    // Basic bucket creation
    public async Task<R2Bucket> CreateBucketAsync(string name)
    {
        return await cf.Accounts.Buckets.CreateAsync(name);
    }

    // Create with location hint and jurisdiction
    public async Task<R2Bucket> CreateEuBucketAsync(string name)
    {
        return await cf.Accounts.Buckets.CreateAsync(
            name,
            locationHint: R2LocationHint.WestEurope,
            jurisdiction: R2Jurisdiction.EuropeanUnion
        );
    }

    public async IAsyncEnumerable<R2Bucket> ListBucketsAsync()
    {
        await foreach (var bucket in cf.Accounts.Buckets.ListAllAsync())
        {
            // Access extensible enum properties with IntelliSense
            if (bucket.Location == R2LocationHint.WestEurope)
            {
                Console.WriteLine($"EU bucket: {bucket.Name}");
            }

            yield return bucket;
        }
    }

    public async Task DeleteBucketAsync(string name)
    {
        await cf.Accounts.Buckets.DeleteAsync(name);
    }
}

The R2Bucket model uses extensible enums for Location, Jurisdiction, and StorageClass properties. See SDK Conventions for details.