Skip to content

Cloudflare R2 Driver

The R2 driver provides integration with Cloudflare R2, Cloudflare's S3-compatible object storage with zero egress fees. The driver extends S3Disk, so all S3 features (multipart uploads, presigned URLs, range requests, conditional writes) are available.

Configuration

FieldTypeRequiredDefaultDescription
driver'r2'YesMust be 'r2'
bucketstringYesR2 bucket name
accountIdstringYesCloudflare account ID
keystringYesR2 API token access key ID
secretstringYesR2 API token secret access key
regionstringNo'auto'R2 region hint (usually 'auto')
urlstringNoCustom domain base URL for public access
visibility'public' | 'private'No'private'Default visibility

Basic Setup

typescript
import { Module } from '@nestjs/common';
import { StorageModule } from '@fozooni/nestjs-storage';

@Module({
  imports: [
    StorageModule.forRoot({
      default: 'r2',
      disks: {
        r2: {
          driver: 'r2',
          bucket: 'my-app-assets',
          accountId: process.env.CF_ACCOUNT_ID,
          key: process.env.R2_ACCESS_KEY_ID,
          secret: process.env.R2_SECRET_ACCESS_KEY,
          url: 'https://assets.example.com', // Custom domain
        },
      },
    }),
  ],
})
export class AppModule {}

Async Configuration with ConfigService

typescript
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { StorageModule } from '@fozooni/nestjs-storage';

@Module({
  imports: [
    ConfigModule.forRoot(),
    StorageModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        default: 'r2',
        disks: {
          r2: {
            driver: 'r2',
            bucket: config.getOrThrow('R2_BUCKET'),
            accountId: config.getOrThrow('CF_ACCOUNT_ID'),
            key: config.getOrThrow('R2_ACCESS_KEY_ID'),
            secret: config.getOrThrow('R2_SECRET_ACCESS_KEY'),
            url: config.get('R2_CUSTOM_DOMAIN'),
          },
        },
      }),
    }),
  ],
})
export class AppModule {}

R2 API Token Setup

To generate R2 API credentials:

  1. Log into the Cloudflare dashboard
  2. Navigate to R2 in the sidebar
  3. Click Manage R2 API Tokens
  4. Click Create API Token
  5. Select permissions:
    • Object Read & Write for full access
    • Object Read for read-only access
  6. Optionally restrict to specific buckets
  7. Copy the Access Key ID and Secret Access Key

Your accountId is visible in the Cloudflare dashboard URL: https://dash.cloudflare.com/<accountId>/r2

TIP

The R2 endpoint is automatically constructed from your account ID:

https://<accountId>.r2.cloudflarestorage.com

You do not need to set the endpoint field manually.

R2 Extends S3Disk

Under the hood, R2Disk extends S3Disk and configures the S3 client to point at the R2 endpoint:

S3Disk
└── R2Disk (endpoint: https://<accountId>.r2.cloudflarestorage.com)

This means every feature documented on the S3 driver page works identically with R2:

  • Presigned URLs via temporaryUrl() and temporaryUploadUrl()
  • Presigned POST via presignedPost()
  • Multipart uploads via initMultipartUpload() / putFileMultipart()
  • Range requests via getRange()
  • Conditional writes via putIfMatch() / putIfNoneMatch()
  • Streaming via getStream() / putStream()
  • Checksums and custom metadata

No Egress Fees

R2's primary advantage over S3 is zero egress fees. Data transfer out is free regardless of volume. This makes R2 particularly cost-effective for:

  • Public asset hosting (images, videos, documents)
  • Frequently downloaded files
  • API responses serving stored content
  • CDN origin storage

R2 vs S3 Differences

While R2 is S3-compatible, there are some differences to be aware of:

FeatureS3R2
Egress feesPer-GB chargesFree
Per-object ACLsSupportedNot supported
Storage classesMultiple (Standard, IA, Glacier, etc.)Single class
RegionsChoose specific regionAutomatic (globally distributed)
VersioningSupportedSupported
Lifecycle rulesFull supportBasic support
S3 SelectSupportedNot supported
ReplicationCross-region replicationNot supported

Public Access and Custom Domains

R2 does not support per-object ACLs. Instead, public access is controlled at the bucket level through Cloudflare's dashboard:

  1. Go to R2 > your bucket > Settings
  2. Under Public access, click Allow Access
  3. Optionally connect a Custom Domain
typescript
// With a custom domain configured:
const disk = this.storage.disk('r2');

// Returns: https://assets.example.com/images/hero.jpg
const url = await disk.url('images/hero.jpg');

No Per-Object ACL Support

Unlike S3, R2 does not support per-object ACLs. The visibility config option controls the default behavior, but you cannot make individual objects public or private. Public access must be enabled or disabled at the bucket level through the Cloudflare dashboard.

typescript
// This will NOT make the individual object public on R2
await disk.put('file.txt', content, { visibility: 'public' });
// The file's accessibility depends on the bucket-level public access setting

Presigned URLs

Even without public bucket access, you can generate presigned URLs for temporary access:

typescript
@Injectable()
export class R2DownloadService {
  constructor(private readonly storage: StorageService) {}

  async getTemporaryLink(path: string): Promise<string> {
    const disk = this.storage.disk('r2');

    // Generate a presigned URL valid for 1 hour
    const url = await disk.temporaryUrl(path, 3600);

    return url;
  }
}

Multipart Uploads

typescript
@Injectable()
export class R2UploadService {
  constructor(private readonly storage: StorageService) {}

  async uploadLargeFile(path: string, stream: Readable) {
    const disk = this.storage.disk('r2');

    // Convenience method handles chunking automatically
    await disk.putFileMultipart(path, stream, {
      partSize: 10 * 1024 * 1024, // 10 MB parts
      concurrency: 3,
    });
  }

  async uploadWithPresignedUrl(path: string) {
    const disk = this.storage.disk('r2');

    // Generate a presigned URL for client-side upload
    const url = await disk.temporaryUploadUrl(path, 3600, {
      contentType: 'application/pdf',
    });

    return { uploadUrl: url };
  }
}

Range Requests and Streaming

typescript
@Controller('assets')
export class AssetController {
  constructor(private readonly storage: StorageService) {}

  @Get(':path(*)')
  @RangeServe()
  async serve(@Param('path') path: string) {
    return { disk: 'r2', path };
  }
}

Using R2 with Cloudflare Workers

If you deploy your NestJS application alongside Cloudflare Workers, R2 can be accessed with lower latency from Workers. For server-side NestJS applications, the driver communicates with R2 via the S3-compatible HTTP API.

typescript
// Standard NestJS usage — communicates via HTTPS
const disk = this.storage.disk('r2');
await disk.put('data/report.json', JSON.stringify(report));

Complete Example: Asset Service

typescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { StorageService } from '@fozooni/nestjs-storage';
import { v4 as uuid } from 'uuid';

@Injectable()
export class AssetService {
  constructor(private readonly storage: StorageService) {}

  private get disk() {
    return this.storage.disk('r2');
  }

  async upload(file: Express.Multer.File): Promise<{ key: string; url: string }> {
    const ext = file.originalname.split('.').pop();
    const key = `assets/${uuid()}.${ext}`;

    await this.disk.put(key, file.buffer, {
      metadata: {
        originalName: file.originalname,
        mimeType: file.mimetype,
      },
    });

    return {
      key,
      url: await this.disk.url(key),
    };
  }

  async getTemporaryUrl(key: string, expiresIn = 3600): Promise<string> {
    if (!(await this.disk.exists(key))) {
      throw new NotFoundException('Asset not found');
    }

    return this.disk.temporaryUrl(key, expiresIn);
  }

  async delete(key: string): Promise<void> {
    await this.disk.delete(key);
  }

  async listAssets(prefix = 'assets/') {
    return this.disk.listContents(prefix);
  }
}

Environment Variables Example

bash
# .env
CF_ACCOUNT_ID=your-cloudflare-account-id
R2_BUCKET=my-app-assets
R2_ACCESS_KEY_ID=your-r2-access-key-id
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
R2_CUSTOM_DOMAIN=https://assets.example.com

Released under the MIT License.