# Cloudflare R2 for Websites: CLI Uploads, Credentials, and What Stays Free

- Source: https://simeoncreatives.com/blog/cloudflare-r2-for-websites
- Hub: Website Design & Development
- Author: Simeon Matheka, Founder & Creative Director
- Published: 2026-08-26
- Updated: 2026-08-26
- Reading time: 14 min

How we keep HTML on the edge and put images plus Lottie files in Cloudflare R2: public CDN URL versus the S3 upload API, the four local secrets, curl and a sync script, Cross-Origin Resource Sharing (CORS), and when the free tier actually ends.

A marketing site should not drag every photograph through the same origin that serves HTML. On simeoncreatives.com the pages go out from Cloudflare as a static build. Covers, team photos, and Lottie files live in Cloudflare R2 (R2). The browser loads them from a public URL. Uploads never go through that URL.

If you skip the split, you get a fat git repo, slow first loads, and deploys that re-upload megabytes of WebP you did not change. If you mix up the two doors, you get 401 on PUT or 404 on the live site while the file sits happily in public/.

## What the bucket is for

R2 is object storage. You PUT a file at a key. You GET that key over HTTPS. There is no PHP folder, no plugin cache, no “uploads” directory on a VPS. For a site we use it as a media content delivery network (CDN): images, icons, short motion files. Not a database. Not a place to hide invoices.

Our keys match the public path. A blog cover in the repo at public/images/blog/some-slug.webp is the object images/blog/some-slug.webp. The React helper assetUrl('/images/blog/some-slug.webp') prefixes the public base when VITE_ASSETS_BASE_URL is set, and leaves the path alone when it is not. Local without a CDN base still works. Production with a CDN base will look broken until that object exists on the bucket.

| In the repo | Object key | Browser asks |
| --- | --- | --- |
| public/images/blog/{slug}.webp | images/blog/{slug}.webp | {CDN}/images/blog/{slug}.webp |
| public/images/blog/{slug}/figure.webp | images/blog/{slug}/figure.webp | {CDN}/images/blog/{slug}/figure.webp |
| public/lottie/badge.json | lottie/badge.json | {CDN}/lottie/badge.json |

![R2 Object Storage dashboard showing one bucket, name masked, with 199 objects totaling 53.56 MB, and a subtitle that storage has zero egress charges](/images/blog/cloudflare-r2-for-websites/r2-object-storage-bucket-list.webp)
*This site’s media bucket: 199 objects, 53.56 MB. HTML stays on the Cloudflare Worker. Photographs and Lottie files live here.*

## What stays free, and when you pay

Standard storage is the class a live website should use. Each month you get 10 GB-month of storage, 1 million Class A operations, and 10 million Class B operations included. Sending the file to the visitor from R2 does not add a bandwidth line. Deletes are not billed.

- **Class A: **writes and mutations. PUT object, list, copy, set Cross-Origin Resource Sharing (CORS), multipart upload. Your command-line sync is this bucket.
- **Class B: **reads. Every visitor who loads a cover is a GET. This is the line a busy site can cross first.

This account, 27 July to 27 August 2026: $0.00 billable. 420 Class A, 21.88 thousand Class B, 53.2 MB stored. That sits under 10 GB, 1 million writes, and 10 million reads with a lot of room left.

You start paying when you exceed those included amounts. Storage past 10 GB-month is $0.015 per extra GB-month. Extra Class A is $4.50 per million. Extra Class B is $0.36 per million. Usage rounds up to the next billing unit, so a tiny overage still counts as a full unit.

![R2 Usage page for 27 July to 27 August showing 0.00 dollars billable, no billable usage incurred yet, 420 Class A operations, 21.88 thousand Class B operations, and 53.2 MB total storage](/images/blog/cloudflare-r2-for-websites/r2-usage-current-period-zero.webp)
*Same period as the bucket size: $0.00. 420 writes, 21.88 thousand reads, 53.2 MB. Add a budget alert if you want a ping before you ever leave the included band.*

Infrequent Access is cheaper per GB and has no free tier, a 30-day minimum, and a retrieval fee. Do not put hero images or blog covers there. Leave it for archives you rarely read.

If another product sits in front of the bucket and that product bills egress, that bill is theirs, not R2. Direct GET from the public bucket hostname does not add R2 egress.

## Two doors: public GET versus signed PUT

The public hostname (a pub-….r2.dev URL, or a custom host like assets.yourdomain.com) is for browsers. Enable public access on the bucket. That URL is safe to commit as VITE_ASSETS_BASE_URL. It is not an upload API.

Uploads use the Amazon Simple Storage Service (S3) compatible endpoint: https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com/BUCKET/key. Sign the request with the access key and secret. Pointing curl at the public r2.dev host with those keys returns 401. Pasting the 32-character access key into both fields does the same. The secret is 64 characters. The access key is 32.

## Credentials

Create an R2 token from the dashboard: R2 → Manage R2 API Tokens → Create User API token. Copy everything on the next screen immediately. The banner is accurate: those values will not be shown again. The Cloudflare API token on that screen is for Wrangler, Cloudflare’s command-line tool. The S3 client block under it is what curl signs.

![R2 token screen after create, with Cloudflare API token value at the top and a highlighted S3 clients section for Access Key ID, Secret Access Key, and the default jurisdiction endpoint](/images/blog/cloudflare-r2-for-websites/r2-s3-client-credentials.webp)
*Two families on one screen. The Cloudflare API token is for Wrangler. Access Key ID, Secret Access Key, and the default S3 endpoint are what curl signs. Copy them now.*

For a site upload script, Object Read & Write on that one bucket is enough. This account also has user tokens with Admin Read & Write on all buckets. That is wider than the CLI sync needs. Prefer a scoped token for the machine that runs PUT.

![User API Tokens table with token names masked, each applied to all buckets, Admin Read and Write permission, issued in 2026, all Active, and a Create User API token button](/images/blog/cloudflare-r2-for-websites/r2-user-api-tokens-list.webp)
*Token inventory. Names masked. Create User API token is top right. Revoke from the row menu when a key leaks or a person leaves.*

| Variable | What it is | Goes in git? |
| --- | --- | --- |
| R2_ACCESS_KEY_ID | 32-character token id | No. Local .env only |
| R2_SECRET_ACCESS_KEY | 64-character secret | No |
| R2_ENDPOINT | https://ACCOUNT_ID.r2.cloudflarestorage.com | No (account id is not a password, still keep it local) |
| R2_BUCKET_NAME | Bucket name, e.g. simeon-creatives-assets | Example file yes. Real .env no |
| VITE_ASSETS_BASE_URL | Public CDN origin, no trailing slash | Yes, in .env.production. It is public on purpose |
| CLOUDFLARE_API_TOKEN | Dashboard token for Wrangler (CORS, bucket admin) | No. Different from the S3 pair |

.env is gitignored. .env.example holds empty names so the next machine knows what to fill. Never paste keys into a pull request, a chat log, or a VITE_ prefix.

## Upload from the command line

We sync with curl and AWS Signature Version 4, region auto, service s3. That is what scripts/upload-r2-assets.sh does: walk public/images and public/lottie, skip dotfiles and README, PUT each file to the same key under the bucket, set Content-Type from the extension (WebP, SVG, JSON, video). Six uploads at a time. Exit non-zero if any status is not 200 or 201.

Full tree:

```bash
bash scripts/upload-r2-assets.sh
```

One new cover (fill the variables from .env, do not type secrets into the shell history if you can avoid it):

```bash
curl -sS -o /dev/null -w '%{http_code}\n' \
  -X PUT \
  --aws-sigv4 'aws:amz:auto:s3' \
  --user "$R2_ACCESS_KEY_ID:$R2_SECRET_ACCESS_KEY" \
  -H 'Content-Type: image/webp' \
  --data-binary '@public/images/blog/your-slug.webp' \
  "$R2_ENDPOINT/$R2_BUCKET_NAME/images/blog/your-slug.webp"
```

Then hit the public URL. You want 200 and Content-Type: image/webp. A 404 means the key is wrong or the upload never landed. A 401 on PUT means the endpoint or the key pair is wrong, not that the bucket is missing the file.

Wrangler is optional here. We use it to apply CORS from scripts/r2-cors.json, which needs CLOUDFLARE_API_TOKEN, not the S3 pair:

```bash
npx wrangler r2 bucket cors set "$R2_BUCKET_NAME" --file scripts/r2-cors.json -y
```

## Point the app at the bucket

Set VITE_ASSETS_BASE_URL to the public origin with no trailing slash. assetUrl() only rewrites paths under /images/ and /lottie/. HTML, CSS, and JS stay on the Worker. That is the point: cache hashed /assets/* on the site, cache media on R2, do not make the HTML origin a photo host.

After a new blog cover, the post is not done when the file is in public/. Upload, confirm CDN 200, then ship. Same rule as the rest of [how we put a static site on Cloudflare](https://simeoncreatives.com/blog/cloudflare-static-website-map): the files you care about have to exist where production actually reads them.

## CORS, only when the browser fetches

An <img> tag does not need CORS. fetch() of a Lottie JSON or an SVG from a preview origin does. Our bucket allows GET and HEAD from any origin, exposes ETag and Content-Type, and caches the preflight for a day. Tighten origins if the bucket ever holds anything that is not meant to be public media.

## Do, and do not

### Do

- **Keep HTML on the edge, media in R2. **Match object keys to public/ paths.
- **Use Standard storage for anything the site loads in a session. **Watch Class B if traffic spikes on images.
- **Store S3 keys in local .env. **Commit only the public CDN base.
- **Set Content-Type on PUT. **Browsers and scrapers trust the header more than the filename.
- **Confirm the CDN URL returns 200 after every new object. **Then purge or wait if you reused a URL.
- **Scope the token to one bucket and Object Read & Write. **Rotate if it leaked.

### Do not

- **Do not PUT to the r2.dev public host. **Use the account S3 endpoint.
- **Do not put R2_SECRET_ACCESS_KEY in VITE_. **The client bundle is public.
- **Do not treat a 32-character string as the secret. **If both values are the same length, you copied the id twice.
- **Do not ship a post with the cover only on disk. **Production will 404 the hero.
- **Do not use Infrequent Access for hot assets. **You pay retrieval and lose the free tier.
- **Do not commit .env, tokens, or a bucket listing of private files. **Public media is fine to GET. Admin keys are not.

That is the whole loop we run when a new image goes on this site: file in public/, PUT to the matching key, CDN 200, then deploy HTML. It is part of [how we ship websites](https://simeoncreatives.com/websites). If a live site is mixing origin uploads with a half-connected bucket, [get in touch](https://simeoncreatives.com/contact).

## FAQs

### Is Cloudflare R2 free for a small marketing site?

Standard storage includes 10 GB-month, 1 million Class A (write) operations, and 10 million Class B (read) operations per month, with no charge to send bytes to the internet from R2. This site’s current period (27 July to 27 August 2026) shows $0.00 billable: 53.2 MB stored, 420 Class A, 21.88 thousand Class B. You start paying when stored data or request counts go over the included amounts. Infrequent Access storage is not in the free tier. Do not use it for images the homepage loads on every visit.

### What credentials do I need to upload from the command line?

Four values in local .env: R2_ACCESS_KEY_ID (32 characters), R2_SECRET_ACCESS_KEY (64 characters), R2_ENDPOINT (https://ACCOUNT_ID.r2.cloudflarestorage.com), and R2_BUCKET_NAME. Create those keys under R2, Manage R2 API Tokens, Create User API token. Copy Access Key ID, Secret Access Key, and the S3 endpoint on the next screen immediately. Cloudflare will not show the secret again. Scope Object Read and Write on that one bucket when you can. The public r2.dev URL is not the endpoint. A Cloudflare dashboard API token is a different credential, used by Wrangler, not by the Amazon Simple Storage Service (S3) style curl PUT we use for files.

### Why do images work on my laptop but 404 in production?

When VITE_ASSETS_BASE_URL is set, the app rewrites /images/ and /lottie/ to the CDN. A file that only exists in git public/ is not on the bucket. Upload, then request the CDN URL and confirm HTTP 200 and the right Content-Type.

### Can I put the secret access key in a VITE_ variable?

No. Vite inlines VITE_ values into the browser bundle. The public CDN base URL may be a VITE_ value. The Amazon Simple Storage Service (S3) secret may not. Keep upload keys in local .env, gitignored, used only by scripts on your machine or Continuous Integration (CI) secrets.

### Do I need Cross-Origin Resource Sharing (CORS) on the bucket?

Not for ordinary <img src> to another host. Yes if JavaScript fetch() loads an SVG or a Lottie JSON from the bucket on a different origin (local preview, another subdomain). We allow GET and HEAD from any origin on this bucket because the files are public media, not private documents.
