---
title: "How to set up Cloudflare R2"
slug: "set-up-cloudflare-r2"
category: "howto"
tags: ["howto", "cloudflare", "r2", "object-storage", "tutorial", "ops", "s3-compatible"]
status: "stable"
last_updated: 2026-05-14
summary: "Create an R2 bucket, generate API keys, attach a public domain, and access it with the AWS CLI or boto3 via the S3-compatible endpoint."
related: ["[[ops/cloudflare]]", "[[ops/cloudflare-r2]]", "[[ops/cloudflare-kv]]", "[[comparisons/vercel-vs-cloudflare]]", "[[howto/set-up-quartz-with-cloudflare-pages]]", "[[coding/python]]"]
---

## Overview

Cloudflare R2 is S3-compatible object storage with no egress fees. This guide creates a bucket, generates R2 API credentials, sets a custom public domain, and verifies access with both the AWS CLI and boto3. The full R2 configuration reference is in [[ops/cloudflare-r2]].

## Prerequisites

- A Cloudflare account with R2 enabled. R2 requires a payment method on file but has a generous free tier (10 GB storage, 1 million Class A operations per month).
- The AWS CLI installed: `pip install awscli` or `brew install awscli`. The CLI is used only for the S3-compatible endpoint; no AWS account is needed.
- Python 3.11+ if you want to use boto3.
- A domain on Cloudflare for the public-access subdomain. See [[ops/cloudflare]] for the DNS configuration context.

## Steps

### 1. Create the R2 bucket

In the Cloudflare dashboard, open R2, then click "Create bucket."

- **Bucket name**: `my-assets` (lowercase, hyphens allowed, no underscores).
- **Location**: Automatic, or pick a region hint closest to your users.

Click "Create bucket." Bucket names are globally unique per account, not globally unique across all of R2.

### 2. Generate R2 API keys

R2 uses a separate API token, not the main Cloudflare API key.

1. In the R2 section, click "Manage R2 API Tokens," then "Create API token."
2. Set the token name, select "Object Read and Write" for the permissions, and scope it to the specific bucket.
3. Click "Create API Token."
4. Copy the **Access Key ID** and **Secret Access Key**. You cannot view the secret again after closing the dialog.

Store them in environment variables:

```bash
export R2_ACCESS_KEY_ID="your-access-key-id"
export R2_SECRET_ACCESS_KEY="your-secret-access-key"
export R2_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
```

The account ID appears in the R2 dashboard URL: `https://dash.cloudflare.com/<account-id>/r2`.

### 3. Attach a public domain

Public buckets let you serve files directly from a URL without signing requests.

1. In the bucket settings, click "Settings," then "Public Access."
2. Under "Custom Domains," click "Connect Domain" and enter `assets.yourdomain.com`.
3. Cloudflare adds a CNAME record automatically if the domain is on Cloudflare DNS.

After activation, objects at `https://assets.yourdomain.com/<key>` are publicly readable.

To allow list access for non-public use, skip this step and use pre-signed URLs via the SDK.

### 4. Configure the AWS CLI profile

```bash
aws configure --profile r2
# AWS Access Key ID: <R2_ACCESS_KEY_ID>
# AWS Secret Access Key: <R2_SECRET_ACCESS_KEY>
# Default region name: auto
# Default output format: json
```

Create `~/.aws/config` entry for the endpoint:

```ini
[profile r2]
region = auto
endpoint_url = https://<account-id>.r2.cloudflarestorage.com
```

### 5. Access with the AWS CLI

```bash
# List buckets.
aws s3 ls --profile r2

# Upload a file.
aws s3 cp ./image.png s3://my-assets/images/image.png --profile r2

# List objects in the bucket.
aws s3 ls s3://my-assets/ --profile r2

# Download an object.
aws s3 cp s3://my-assets/images/image.png ./downloaded.png --profile r2
```

### 6. Access with boto3

```python
import boto3, os

r2 = boto3.client(
    "s3",
    endpoint_url=os.environ["R2_ENDPOINT"],
    aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
    aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
    region_name="auto",
)

# Upload.
r2.upload_file("image.png", "my-assets", "images/image.png")

# Generate a pre-signed URL (1-hour expiry).
url = r2.generate_presigned_url(
    "get_object",
    Params={"Bucket": "my-assets", "Key": "images/image.png"},
    ExpiresIn=3600,
)
print(url)
```

See [[coding/python]] for the async variant using `aiobotocore`.

## Verify it worked

```bash
# 1. CLI can list objects.
aws s3 ls s3://my-assets/ --profile r2

# 2. Upload a test file.
echo "hello r2" > test.txt
aws s3 cp test.txt s3://my-assets/test.txt --profile r2

# 3. Confirm it is publicly readable via the custom domain.
curl -sI https://assets.yourdomain.com/test.txt | head -1
# expected: HTTP/2 200

# 4. Content matches.
curl -s https://assets.yourdomain.com/test.txt
# expected: hello r2
```

## Common errors

- `InvalidAccessKeyId`. The Access Key ID is from the main Cloudflare API token, not the R2 token. Generate a dedicated R2 API token.
- `403 Forbidden` on public URL. The bucket's public access is disabled. Check bucket Settings, Public Access.
- AWS CLI uses the wrong region. Set `region = auto` in the profile; R2 ignores the region string but the CLI requires one.
- boto3 `EndpointResolutionError`. The `endpoint_url` is missing the `https://` prefix or the account ID is wrong.
- Custom domain not active. DNS propagation takes up to five minutes. Check with `dig CNAME assets.yourdomain.com`.

## Related

- [[ops/cloudflare]]
- [[ops/cloudflare-r2]]
- [[ops/cloudflare-kv]]
- [[comparisons/vercel-vs-cloudflare]]
- [[howto/set-up-quartz-with-cloudflare-pages]]
- [[coding/python]]
