# R2 storage

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

```bash
npx extraorbital add r2
```

```txt
✓ 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`](/docs/quickstart#how-autopilot-decides) without naming it.

R2 is S3-compatible, so every S3 client works against it unchanged. The reason to
pick it over [`s3`](/docs/resources/s3) is bandwidth: **reading your own data back
costs nothing**, where S3 charges $0.09 per gigabyte.

## What it writes

| Variable | Example |
| --- | --- |
| `R2_BUCKET` | `cosmic-otter-default` |
| `R2_ENDPOINT` | `https://a1b2….r2.cloudflarestorage.com` |
| `R2_ACCOUNT_ID` | `a1b2…` |
| `R2_ACCESS_KEY_ID` | `f267e341f3dd4697bd3b9f71dd96247f` |
| `R2_SECRET_ACCESS_KEY` | `9c8e…` |

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

## Options

```bash
npx extraorbital add r2 [--slug <name>] [--public] [--region <jurisdiction>]
```

| Flag | Default | Description |
| --- | --- | --- |
| `--slug` | `default` | A second, fully separate bucket in the same project |
| `--public` | off | Objects are publicly readable |
| `--region` | `default` | An 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 |
| Egress | **free** |

`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`](/docs/resources/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

```ts title="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](/docs/resources/b2) — cheaper per byte, if you read less than you store
- [Quickstart](/docs/quickstart) — slugs, idempotency and sharing
- [Pricing](/pricing) — plans, allowances and rates

---

More for agents: [Docs index](https://extraorbital.dev/sitemap.md) · [llms.txt](https://extraorbital.dev/llms.txt) · [agents.md](https://extraorbital.dev/agents.md)
