transcodelyproduct updateCloudflare R2object storagecost optimization

Transcode Video to Cloudflare R2: Zero Egress, S3-Compatible

7 min read Dimitar Todorov

Cloudflare R2 is now a first-class storage origin in Transcodely. Point a transcoding job at an R2 bucket with just your account ID — then deliver the output to viewers with zero egress fees.

Transcodely has always been storage-agnostic. You point a job at a file on S3, GCS, or any HTTP URL, we encode it, and we write the renditions back to a bucket you own. We never charged egress and we never forced you onto our storage — that was the whole point.

Today, Cloudflare R2 joins that list as a first-class origin. You can read source video from an R2 bucket, write transcoded outputs to one, or both. And because R2 has zero egress fees, it changes the math on the most expensive part of running video at scale: delivery.

Encoding is a one-time cost. Delivery is forever.

Here is the thing nobody tells you when you start shipping video: the encode bill is the small one.

You pay to transcode a file once. Then you pay to deliver it every single time someone presses play — and at scale, that egress dwarfs everything else. AWS bills internet data-transfer-out at roughly $0.09/GB once you are past the free tier. A modest channel pushing 5 TB of video a month is paying around $450/month in egress alone, before storage, before requests, before a single minute of encoding.

Cloudflare R2 charges $0 for egress. Full stop. You still pay for storage (~$0.015/GB-month) and operations, but data leaving the bucket is free, forever. Serve your renditions from R2 — through Cloudflare’s CDN, a public bucket, or a custom domain — and that $450 line item goes to zero. Your encode bill does not change. Your delivery bill disappears.

That is exactly the kind of cost asymmetry I spent years chasing by hand, and it pairs naturally with the other levers for cutting encoding spend.

A first-class origin, not an S3 footnote

R2 speaks the S3 API, so the lazy way to “support R2” is to tell people to use the S3 origin and figure out the endpoint URL themselves. We didn’t do that.

R2 gets its own provider with an ergonomic, R2-shaped config. The only thing you really need is your Cloudflare account ID — we derive the endpoint for you:

Jurisdiction Derived endpoint
default <account_id>.r2.cloudflarestorage.com
eu <account_id>.eu.r2.cloudflarestorage.com
fedramp <account_id>.fedramp.r2.cloudflarestorage.com

No endpoint string to copy-paste wrong. No region field to guess at (R2 ignores it; S3 makes you supply one anyway). Just an account ID and an optional jurisdiction for data residency. It is the difference between an integration that technically works and one that feels like it was built for the thing you’re actually using — the Stripe-style ergonomics we try to apply everywhere.

Setting it up

Three steps, about two minutes.

1. Create an R2 API token. In the Cloudflare dashboard, go to R2 → Manage R2 API Tokens and create a token scoped to Object Read & Write (or Object Read for an input-only origin). Cloudflare hands you an Access Key ID and Secret Access Key — copy both now, the secret is shown once.

2. Grab your account ID. It’s the 32-character lowercase hex string on the R2 → Overview page.

3. Create the origin. The recommended form passes account_id and lets us do the rest:

curl -X POST https://api.transcodely.com/transcodely.v1.OriginService/Create \
  -H "Authorization: Bearer {{API_KEY}}" \
  -H "X-Organization-ID: {{ORG_ID}}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Cloudflare R2 Outputs",
    "permissions": ["read", "write"],
    "base_path": "videos/",
    "path_template": "{date}/{job_id}/{codec}_{resolution}",
    "r2": {
      "bucket": "acme-transcoded-outputs",
      "account_id": "f037e1abcd0987654321fedcba012345",
      "credentials": {
        "access_key_id": "...",
        "secret_access_key": "..."
      }
    }
  }'

Credentials are validated against the bucket at creation time, so you find out immediately if the token is missing a permission — not on your first failed job. The response comes back active with a validation block confirming read and write actually worked.

In your language of choice

Every SDK exposes R2 the same way. TypeScript:

import { OriginPermission, R2Jurisdiction, Transcodely } from "transcodely";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });

const origin = await client.origins.create({
  name: "My R2 Origin",
  permissions: [OriginPermission.READ, OriginPermission.WRITE],
  r2: {
    bucket: process.env.R2_BUCKET ?? "media",
    accountId: process.env.R2_ACCOUNT_ID!,
    jurisdiction: R2Jurisdiction.DEFAULT, // or EU, FEDRAMP
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY_ID!,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
    },
  },
});

Python:

from transcodely import Transcodely

with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
    origin = client.origins.create(
        name="My R2 Origin",
        permissions=["read", "write"],
        r2={
            "bucket": os.environ.get("R2_BUCKET", "media"),
            "account_id": os.environ["R2_ACCOUNT_ID"],
            "jurisdiction": "default",  # or "eu", "fedramp"
            "credentials": {
                "access_key_id": os.environ["R2_ACCESS_KEY_ID"],
                "secret_access_key": os.environ["R2_SECRET_ACCESS_KEY"],
            },
        },
    )

Go:

origin, err := client.Origins.Create(context.Background(), &transcodely.OriginCreateParams{
    Name:        "R2 source",
    Permissions: []transcodely.OriginPermission{transcodely.OriginPermissionRead},
    R2: &transcodely.R2OriginConfig{
        Bucket:       "my-r2-bucket",
        AccountId:    os.Getenv("R2_ACCOUNT_ID"),   // 32 lowercase hex chars
        Jurisdiction: transcodely.R2JurisdictionEU, // optional: Default, EU, FedRAMP
        Credentials: &transcodely.S3Credentials{
            AccessKeyId:     os.Getenv("R2_ACCESS_KEY_ID"),
            SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
        },
    },
})

R2 reuses the S3 credential shape because R2 is S3-compatible underneath — but you never touch an endpoint or a region. If you have a custom domain bound to the bucket, or a jurisdiction we don’t enumerate yet, pass an explicit endpoint instead of account_id (exactly one of the two).

Using it in a job

An origin is just storage with permissions attached. Reference one for input, one for output — they can be the same R2 bucket or different ones:

curl -X POST https://api.transcodely.com/transcodely.v1.JobService/Create \
  -H "Authorization: Bearer {{API_KEY}}" \
  -H "X-Organization-ID: {{ORG_ID}}" \
  -H "Content-Type: application/json" \
  -d '{
    "input_origin_id": "ori_input12345",
    "input_path": "uploads/my-video.mp4",
    "output_origin_id": "ori_r2outputs",
    "outputs": [
      {
        "type": "mp4",
        "video": [{ "codec": "h264", "resolution": "1080p", "quality": "standard" }]
      }
    ]
  }'

A common pattern is a read-only input origin on whatever storage your uploads already land in, and a write-only R2 output origin that feeds your CDN. The path_template on the output origin keeps everything tidy — {date}/{job_id}/{codec}_{resolution} resolves to paths like 2026-05-29/job_a1b2c3/h264_1080p.mp4, so renditions never collide.

Residency and security

The jurisdiction field isn’t decoration. Set it to eu and your bucket — and the endpoint we talk to — stays inside Cloudflare’s EU perimeter; fedramp targets the FedRAMP environment. Pick it once at creation; the bucket, account ID, and jurisdiction are immutable afterward, which keeps the storage location auditable.

Credentials are encrypted at rest and never returned in any API response — after creation you can confirm that credentials exist, but you can’t read them back. When it’s time to rotate an R2 token, push the new keys through the Update endpoint and re-validate; nothing else about the origin moves.

When R2 is the right call (and when it isn’t)

R2 shines as an output origin for content you deliver a lot: VoD libraries, course platforms, anything where the same files get streamed thousands of times. The zero-egress economics compound with every view. It’s just as happy as an input origin if your sources already live there.

If your delivery volume is tiny, or your viewers and your existing S3 region are in the same place and you’ve already negotiated transfer pricing, the egress win may be marginal — stick with what you have. And if you’d rather not manage buckets, CDNs, and signed URLs at all, Transcodely Video Hosting bundles storage and global delivery on top of the same encoder. R2 origins are for when you want to own the delivery layer; hosting is for when you don’t.

Get started

Add an R2 origin from the Origins tab in your dashboard, or with the API call above. The full walkthrough — token scopes, jurisdictions, the explicit-endpoint escape hatch — lives in the storage setup guide and the origins reference.

As always, if you hit something weird, email me directly — still a one-person company, still the guy who wrote the code.

Happy transcoding.

Topics

transcodelyproduct updateCloudflare R2object storagecost optimization

Share this article