STORAGE · R2

Zero-egress object storage with R2

After this lesson you'll be able to create an R2 bucket, read and write objects from a Worker binding, connect to the same bucket with a stock S3 SDK, and reason about what R2 actually costs you.

R2 is Cloudflare's object storage product: buckets and keys, just like Amazon S3, for storing any blob — images, video, backups, JSON blobs, model weights, whatever doesn't fit neatly into a database row. The headline difference from S3 and every other major cloud object store is pricing structure, not features: R2 charges nothing for egress — the bandwidth cost of serving data back out to the internet or to another cloud. On S3, egress is often the line item that dwarfs storage cost once you're serving any real traffic; on R2 it's zero, by design, regardless of how much data you move out.

Why can Cloudflare afford this? Cloudflare's business model already assumes massive bandwidth flowing through its network for CDN and DDoS protection — egress isn't a scarce resource they need to meter to recoup transit costs the way a pure storage vendor does. R2 is explicitly positioned as the product that removes the "egress tax" that makes multi-cloud or CDN-fronted S3 architectures expensive.

How it works

R2 is S3-compatible at the API level: it implements a large subset of the S3 REST API (PutObject, GetObject, HeadObject, ListObjectsV2, multipart upload operations, CopyObject, and bucket-level operations like CreateBucket/DeleteBucket). That means existing S3 SDKs — the AWS SDK for JS, boto3, etc. — work against R2 by pointing them at a different endpoint and different credentials, with no code rewrite for the common operations. Notable gaps: no ACLs, no object tagging, no versioning, and some S3 encryption modes aren't supported — check the S3 compatibility matrix before assuming a niche S3 feature works.

There are two ways to talk to a bucket:

Buckets are private by default. Nothing is reachable over the public internet until you explicitly enable public access or front the bucket with a Worker or custom domain — covered in the next lesson.

Worked example

1. Create a bucket

npx wrangler r2 bucket create my-app-uploads

Bucket names: lowercase letters, numbers, and hyphens only, 3-63 characters, can't start or end with a hyphen.

2. Bind it to a Worker

// wrangler.jsonc
{
  "r2_buckets": [
    {
      "binding": "MY_BUCKET",
      "bucket_name": "my-app-uploads"
    }
  ]
}
export interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const key = url.pathname.slice(1); // "/avatar.png" -> "avatar.png"

    if (req.method === "PUT") {
      await env.MY_BUCKET.put(key, req.body, {
        httpMetadata: { contentType: req.headers.get("content-type") ?? undefined },
      });
      return new Response(`Stored ${key}`, { status: 201 });
    }

    if (req.method === "GET") {
      const object = await env.MY_BUCKET.get(key);
      if (object === null) return new Response("Not found", { status: 404 });

      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set("etag", object.httpEtag);
      return new Response(object.body, { headers });
    }

    if (req.method === "DELETE") {
      await env.MY_BUCKET.delete(key);
      return new Response(null, { status: 204 });
    }

    return new Response("Method not allowed", { status: 405 });
  },
};

3. Connect with an existing S3 SDK

First create an R2 API token (R2 dashboard → Manage API Tokens) scoped to Object Read & Write, optionally restricted to one bucket. This gives you an Access Key ID and a Secret Access Key — save the secret immediately, it isn't retrievable afterward. Then use it exactly like an S3 credential pair:

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

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: R2_ACCESS_KEY_ID,
    secretAccessKey: R2_SECRET_ACCESS_KEY,
  },
});

await s3.send(new PutObjectCommand({
  Bucket: "my-app-uploads",
  Key: "reports/2026-07.csv",
  Body: csvBuffer,
  ContentType: "text/csv",
}));

const { Body } = await s3.send(new GetObjectCommand({
  Bucket: "my-app-uploads",
  Key: "reports/2026-07.csv",
}));

This is the same @aws-sdk/client-s3 package you'd use against real S3 — only the endpoint, region, and credentials change. It's how you migrate an existing S3-backed service to R2 with minimal code churn, or run one codebase against either provider.

Pricing

Verified from the R2 pricing page at time of writing — check it directly before relying on exact figures, since pricing can change:

ItemStandard storageInfrequent Access storage
Storage$0.015 / GB-month$0.01 / GB-month
Class A operations (writes/lists — PutObject, ListObjects, CopyObject, etc.)$4.50 / million requests$9.00 / million requests
Class B operations (reads — GetObject, HeadObject, etc.)$0.36 / million requests$0.90 / million requests
Egress (data transfer out)$0 — always, at any volume

DeleteObject, DeleteBucket, and AbortMultipartUpload are free. Infrequent Access storage adds a per-GB retrieval fee and a 30-day minimum storage duration; Standard storage has no retrieval fee. The free tier (Standard storage only) includes 10 GB-month storage, 1 million Class A operations, 10 million Class B operations, and unlimited egress per month.

The point to internalize: egress is the one line that's always zero. Storage and operations are still metered and billed — R2 removed one specific cost, not all of them.

Use cases

Pitfall: "no egress fees" doesn't mean "no fees." Class A and Class B operations are billed per request regardless of how much or how little data each request moves. A workload doing millions of small GetObject or PutObject calls — e.g. one object per user session, or a naive list-then-get loop hitting the bucket key-by-key instead of batching — can rack up real operations cost even though every byte of egress is free. Before assuming R2 is cheap for a given workload, estimate request volume, not just data volume: high-request-count, low-data-volume patterns (lots of tiny objects, frequent metadata checks, chatty list operations) are where R2's bill shows up.
Primary source

The R2 documentation is the canonical reference for concepts and the Workers/S3 APIs; pair it with the R2 pricing page for current numbers, since pricing is subject to change.

A Worker calls env.MY_BUCKET.get() once per incoming request to serve a large video file to end users, at high traffic volume. Which R2 cost actually scales with that traffic?
Without scrolling up: what are the two ways to connect to an R2 bucket, and when would you pick each?
Reveal

The Workers binding (env.MY_BUCKET) for code running inside a Worker — no HTTP round trip or S3 signing. The S3-compatible API (endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com, region auto, R2-specific access key/secret) for code outside Workers or for reusing an existing S3 SDK with minimal changes.

Anything above unclear — the S3 compatibility gaps, Infrequent Access tradeoffs, or how bucket bindings differ from the S3 API path? Ask your AI teacher before moving on.
← Previous: Connect Workers to your existing Postgres/MySQL with Hyperdrive Next: Direct uploads and public access patterns in R2 →