R2 storage

A Cloudflare R2 bucket with credentials scoped to it, and no egress fees.

AI/LLM: this page is available in plain markdown at /docs/resources/r2.md

npx extraorbital add r2
✓ Provisioned r2/default (bucket cosmic-otter-default)
✓ Wrote 5 variables to .env

  R2_BUCKET, R2_ENDPOINT, R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY

A project that mentions any R2_* variable gets this from extraorbital provision without naming it.

R2 is S3-compatible, so every S3 client works against it unchanged. The reason to pick it over s3 is bandwidth: reading your own data back costs nothing, where S3 charges $0.09 per gigabyte.

What it writes

VariableExample
R2_BUCKETcosmic-otter-default
R2_ENDPOINThttps://a1b2….r2.cloudflarestorage.com
R2_ACCOUNT_IDa1b2…
R2_ACCESS_KEY_IDf267e341f3dd4697bd3b9f71dd96247f
R2_SECRET_ACCESS_KEY9c8e…

The keys reach that bucket and no other, so an agent can never read another project's objects.

Options

npx extraorbital add r2 [--slug <name>] [--public] [--region <jurisdiction>]
FlagDefaultDescription
--slugdefaultA second, fully separate bucket in the same project
--publicoffObjects are publicly readable
--regiondefaultAn R2 jurisdiction: default, eu or fedramp

R2 has no regions — it has jurisdictions, which decide where objects may physically live. --region eu keeps them in the European Union. Anything that is not one of those three is an error rather than a quiet fall back to default: if you asked for eu because you have to, being handed an unrestricted bucket would be worse than being told no.

A jurisdictional bucket answers only on its own endpoint, and R2_ENDPOINT is written accordingly.

Options apply at creation. Re-running add with different options returns the existing bucket unchanged — remove it first if you need a different one.

What it costs

Cloudflare's published rates, passed on unchanged, counted against your plan's allowance:

Rate
Storage$0.015 / GB-month
Class A operations (writes, lists)$4.50 / million
Class B operations (reads, heads)$0.36 / million
Egressfree

DeleteObject, DeleteBucket and AbortMultipartUpload are free, so cleaning up after yourself costs nothing.

Usage appears in extraorbital ledger a day at a time, once each UTC day has closed. A day still running is a number that keeps moving, so it is not billed until it has stopped.

File retention on the Free plan

On the Free plan, files are deleted 30 days after they are uploaded. Cloudflare does this itself, as a bucket lifecycle rule; nothing of ours sweeps your bucket.

Paying lifts the rule, and the rule is lifted within the hour. But lifting it only stops future deletions — it does not bring back files that have already expired, because R2's expiry is an outright delete with nothing behind it. If that matters to you, b2 keeps ten extra days in which an upgrade can still recover the files.

Two details worth knowing:

  • Expiry is approximate. Cloudflare removes objects typically within 24 hours of the deadline, so thirty days means thirty days and a bit.
  • You cannot change this yourself. The credentials you are given reach objects, not bucket configuration, which is what makes the rule a rule.

This is separate from the 30-day idle rule that deletes an unused resource: a busy free bucket survives that one and still loses last month's files.

Use it

storage.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const r2 = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  // R2 addresses a bucket as a path under the account host, not as a
  // subdomain of it. Without this an SDK on its defaults finds nothing.
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

await r2.send(
  new PutObjectCommand({ Bucket: process.env.R2_BUCKET, Key: "report.json", Body: data }),
);

Next

  • B2 storage — cheaper per byte, if you read less than you store
  • Quickstart — slugs, idempotency and sharing
  • Pricing — plans, allowances and rates