How to Configure a Cloudflare R2 Bucket: Public Development URLs vs Custom Domains

Use the auto-generated https://pub-*.r2.dev URL for local development and a custom domain for production by setting the CLOUDFLARE_R2_URL environment variable in your Cloudflare Next.js application.

The ifindev/fullstack-next-cloudflare repository demonstrates a clean pattern for handling file uploads to Cloudflare R2 storage while seamlessly switching between development and production URL schemes. By abstracting the bucket URL behind a single environment variable, you can leverage instant public development URLs during local testing and switch to branded custom domains in production without modifying application code.

How the R2 URL Architecture Works

The application stores files in a Cloudflare R2 bucket bound to the Worker via wrangler.jsonc. During request handling, the helper in src/lib/r2.ts obtains the bucket from the Cloudflare environment, uploads the file, and constructs a public URL using the base value from CLOUDFLARE_R2_URL.

The Bucket Binding Configuration

The R2 bucket binding is declared in wrangler.jsonc, making the bucket available to your Next.js application running on Cloudflare Workers:

// wrangler.jsonc
{
  "r2_buckets": [
    {
      "bucket_name": "next-cf-app-bucket",
      "binding": "next_cf_app_bucket"
    }
  ]
}

This binding name (next_cf_app_bucket) is how your code accesses the R2 storage API within the Worker environment.

The URL Construction Logic

In src/lib/r2.ts, the public URL is constructed by concatenating the environment variable with the file key:

// src/lib/r2.ts
const { env } = await getCloudflareContext();
const publicUrl = `https://${(env as any).CLOUDFLARE_R2_URL}/${key}`;

The value of CLOUDFLARE_R2_URL determines whether you serve files from the temporary development endpoint or your branded domain.

Development vs Production URLs

Cloudflare R2 provides two distinct URL strategies for accessing uploaded files. The repository leverages both through environment-specific configuration.

Public Development URLs

Public Development URLs are auto-generated by Cloudflare when you create an R2 bucket (format: https://pub-a1b2c3d4e5f6g7h8i9j0.r2.dev).

Characteristics:

  • Instantly available after bucket creation with no DNS configuration
  • Free SSL provided automatically by Cloudflare
  • Intended for testing only due to temporary nature and branding

Set this in your local environment file:


# .dev.vars

CLOUDFLARE_R2_URL=https://pub-a1b2c3d4e5f6g7h8i9j0.r2.dev

Custom Domains

Custom domains (e.g., https://files.yourdomain.com) require manual configuration in the Cloudflare dashboard but provide production-grade reliability.

Benefits:

  • Brand consistency with your own hostname
  • CDN-level caching and optimized edge delivery
  • Custom SSL certificates and advanced security controls
  • Permanent URLs suitable for production databases

Configure this as a secret in production:

echo "https://files.yourdomain.com" | wrangler secret put CLOUDFLARE_R2_URL

Implementation in Code

The uploadToR2 function handles the complete upload flow and URL generation, remaining agnostic to whether you are using development or production URLs.

The uploadToR2 Helper

Located in src/lib/r2.ts, this function generates a unique key, uploads the file buffer, and returns the complete public URL:

// src/lib/r2.ts
export async function uploadToR2(
  file: File,
  folder = "uploads",
): Promise<UploadResult> {
  const { env } = await getCloudflareContext();

  const timestamp = Date.now();
  const randomId = Math.random().toString(36).substring(2, 15);
  const ext = file.name.split(".").pop() ?? "bin";
  const key = `${folder}/${timestamp}_${randomId}.${ext}`;

  const arrayBuffer = await file.arrayBuffer();

  await env.next_cf_app_bucket.put(key, arrayBuffer, {
    httpMetadata: { 
      contentType: file.type, 
      cacheControl: "public, max-age=31536000" 
    },
    customMetadata: { 
      originalName: file.name, 
      uploadedAt: new Date().toISOString(), 
      size: `${file.size}` 
    },
  });

  const publicUrl = `https://${(env as any).CLOUDFLARE_R2_URL}/${key}`;
  return { success: true, url: publicUrl, key };
}

Consuming the Upload Function

Modules such as the Todo actions import this helper to handle file attachments:

// src/modules/todos/actions/create-todo.action.ts
import { uploadToR2 } from "@/lib/r2";

export async function createTodoAction(file: File) {
  const uploadResult = await uploadToR2(file);
  if (!uploadResult.success) throw new Error(uploadResult.error);
  // Store uploadResult.url in your database with the todo record
}

Setting Up a Custom Domain

To switch from public development URLs to a custom domain in production:

  1. Open the Cloudflare Dashboard → R2 → Your Bucket → Custom Domains
  2. Click "Connect Domain" and enter your desired hostname (e.g., files.yourdomain.com)
  3. Follow DNS instructions to create a CNAME record pointing to your bucket's .r2.dev endpoint
  4. Wait for SSL issuance (usually completes within minutes)
  5. Set the production secret using Wrangler:
wrangler secret put CLOUDFLARE_R2_URL

# Enter: https://files.yourdomain.com

Once configured, all existing and new files will be accessible via your custom domain without requiring any code changes, as the uploadToR2 function dynamically reads the URL base from the environment.

Summary

  • Single environment variable controls the URL scheme: Set CLOUDFLARE_R2_URL to either your public development URL or custom domain
  • No code changes required: The uploadToR2 helper in src/lib/r2.ts constructs URLs dynamically based on the environment
  • Bucket binding is static: Defined in wrangler.jsonc as next_cf_app_bucket and referenced consistently across environments
  • Development uses auto-generated URLs: Format https://pub-*.r2.dev requires zero configuration
  • Production requires custom domain setup: Configure via Cloudflare dashboard for branded, cached file delivery

Frequently Asked Questions

What is the difference between R2 public development URLs and custom domains?

Public development URLs are temporary endpoints automatically generated by Cloudflare when you create an R2 bucket (e.g., https://pub-xxx.r2.dev), suitable for local testing but lacking branding and advanced caching. Custom domains are hostnames you configure yourself (e.g., https://files.example.com) that provide CDN-level performance, custom SSL, and professional branding required for production applications.

How do I configure the R2 bucket URL for local development?

Create a .dev.vars file in your project root and set CLOUDFLARE_R2_URL to your bucket's public development URL: CLOUDFLARE_R2_URL=https://pub-a1b2c3d4e5f6g7h8i9j0.r2.dev. The src/lib/r2.ts helper automatically reads this variable when running wrangler dev or Next.js local development mode.

Where is the R2 bucket binding configured in the Next.js app?

The binding is declared in wrangler.jsonc under the r2_buckets array with the name next_cf_app_bucket. This binding name is referenced in code via the Cloudflare environment object (env.next_cf_app_bucket) to execute put, get, and list operations on the bucket.

Do I need to modify code when switching from development to production URLs?

No. The application code remains identical between environments because the URL construction logic in uploadToR2 references the CLOUDFLARE_R2_URL environment variable. You only need to change the value of this variable via wrangler secret put for production or .dev.vars for local development, leaving the TypeScript implementation in src/lib/r2.ts untouched.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →