Tutorials Build a Course Platform on Cloudflare Chapter 2

Streaming Video on R2 — The $0-Egress Budget Workaround

Course PlatformChapter 2 of the Build a Course Platform on Cloudflare28 minMay 29, 2026Intermediate

Chapter 1 called R2's $0-egress model the "budget headliner" of this stack. This chapter is why. By the end you'll know exactly how to put 200 GB of course video on Cloudflare for under $5 a month including bandwidth, the encoding command line that makes it stream cleanly, the Worker code that gates each download against your paywall, and the exact size/feature trigger that means it's time to graduate to Cloudflare Stream.

The Headline Math: Why R2 Wins for Course Libraries

Two Cloudflare products can host video. They are priced wildly differently.

R2 (object storage)Stream (managed video)
Storage$0.015 / GB-month$5 / 1,000 minutes-month
Bandwidth (egress)$0 — always$1 / 1,000 minutes delivered
EncodingPre-encode locally with ffmpeg (free)Included (uploads in, HLS out)
Adaptive bitratePre-render multiple HLS ladders, or ship MP4Automatic per-viewer
Signed playback tokensYou build via Worker proxy + JWTBuilt-in
DRM (Widevine / FairPlay)Not supportedAvailable on top-tier plan
Free tier10 GB storage / monthNone for delivery; small encoding allowance

That bandwidth row is the whole story. Egress — the bytes leaving Cloudflare's network and reaching your viewers — is the line item that crushes everyone else's stack. Every other video host (Mux, Cloudinary, AWS S3 + CloudFront, Vimeo Pro) bills for it. R2 simply doesn't. Whether you stream 1 GB this month or 100 TB, Cloudflare charges you $0 for the bandwidth.

A concrete cost example

Pick a realistic shape for a small course library:

200 GB of pre-encoded H.264 MP4 (≈ 100 hours of 1080p content) streamed 10 TB / month (≈ 5,000 hours of viewing — a healthy small-product number).

Line itemR2Stream
Storage200 GB × $0.015 = $3≈ 6,000 min × $5/1k = $30
Delivery (10 TB ≈ ~440k stream-min @ 3 Mbps)$0$440
Total / month$3~$470

That's not a 2× or 5× difference. It's roughly 150×. For most indie course sites — small library, modest concurrent viewership — that's not a close call. R2 wins until you cross specific feature thresholds we'll cover at the end of this chapter.

The Upload Pipeline

You have a .mov from Screen Studio / OBS / a Mac screen recording. Here's how it gets onto R2 in a playable shape.

Encode locally with ffmpeg

Don't upload the raw .mov. A 5-minute screen recording is often 2–4 GB straight out of QuickTime — re-encoded with ffmpeg, the same content is 50–150 MB and indistinguishable visually.

The single command that produces a web-ready, seekable, progressive MP4:

ffmpeg -i input.mov \
  -c:v libx264 -preset slow -crf 22 \
  -c:a aac -b:a 128k \
  -movflags +faststart \
  output.mp4

Read it part by part:

FlagWhat it does
-c:v libx264Video codec H.264 (universal browser support)
-preset slowSlower encode, smaller file. Worth the wait offline.
-crf 22Quality target. 18 = visually lossless, 22 = small + great, 28 = small + ok.
-c:a aac -b:a 128kAAC audio at 128 kbps (every browser plays this)
-movflags +faststartCritical: moves the MP4's moov atom to the front so playback can start before the file is fully downloaded

That +faststart flag is the line that turns a "file" into a "stream." Without it, the browser has to download the entire MP4 before any frame plays, because the codec metadata is at the end. With it, playback starts in 100 ms.

Upload to R2

Two ways. Either works.

# CLI: one command per file
wrangler r2 object put my-bucket/courses/swift/01-intro.mp4 \
  --file ./01-intro.mp4 \
  --content-type video/mp4 \
  --remote

Or drag-and-drop in the Cloudflare dashboard for one-off uploads. For files bigger than ~5 GB, R2 wants a multipart upload (the dashboard does this automatically; the S3 API supports it explicitly).

For a course-content pipeline that's going to repeat, a one-line shell script over a directory of pre-encoded MP4s is plenty:

for f in encoded/*.mp4; do
  key="courses/$(basename "$f")"
  wrangler r2 object put "my-bucket/$key" --file "$f" \
    --content-type video/mp4 --remote
done

Two Ways to Serve What's in the Bucket

R2 has two delivery models, and a course site uses both for different content.

A) Public bucket (free content, free previews, marketing video)

When you mark an R2 bucket public — or attach a custom domain like videos.yourdomain.com — any object key becomes a plain HTTPS URL:

https://videos.yourdomain.com/courses/swift/01-intro.mp4

Drop that URL into an HTML <video> tag and you're done:

<video src="https://videos.yourdomain.com/courses/swift/01-intro.mp4"
       controls preload="metadata"></video>

The browser does byte-range requests, R2 serves them through Cloudflare's CDN, seeking works, every device plays it. Zero auth = zero code. Use this for free preview videos, marketing trailers, and the "first chapter is free" content.

B) Worker-proxied (paid content behind the paywall)

For Pro-only content, you don't want the raw bucket URL exposed at all. Instead, requests go to your Worker, which checks auth + entitlement, and then pulls bytes from R2 on behalf of the authorised user.

Loading diagram…

Figure 1 — A Worker proxy gives you the full power of "check who they are, check what they paid for, then serve" without exposing the raw bucket URLs. The R2 bucket stays private; the Worker is the only thing that holds the binding.

A complete, production-shaped Worker for this — under 50 lines, byte-range support included:

// src/index.js — Cloudflare Worker
export default {
  async fetch(req, env) {
    const url = new URL(req.url);
    const match = url.pathname.match(/^\/v\/(.+)$/);
    if (!match) return new Response("Not found", { status: 404 });
 
    // 1. Identify the viewer (Ch 3 covers the cookie + JWT details)
    const session = readSessionCookie(req);
    const user = session && (await verifyJWT(session, env.JWT_SECRET));
 
    // 2. Decide whether they're allowed (Ch 4 covers the gate logic)
    const videoKey = match[1];
    const isFree = await isFreeVideo(videoKey, env.DB);
    const subscribed = user && (await isSubscribed(user.id, env.DB));
    if (!isFree && !user) return new Response("Sign in", { status: 401 });
    if (!isFree && !subscribed) return new Response("Pro only", { status: 402 });
 
    // 3. Serve from R2, honouring byte-range so seek works
    const range = req.headers.get("Range");
    const obj = range
      ? await env.VIDEOS.get(videoKey, { range: parseRange(range) })
      : await env.VIDEOS.get(videoKey);
    if (!obj) return new Response("Not found", { status: 404 });
 
    return new Response(obj.body, {
      status: range ? 206 : 200,
      headers: {
        "Content-Type": "video/mp4",
        "Accept-Ranges": "bytes",
        "Content-Length": String(obj.size),
        "Cache-Control": "private, max-age=3600",
      },
    });
  },
};

Wire env.VIDEOS to your R2 bucket in wrangler.toml:

[[r2_buckets]]
binding = "VIDEOS"
bucket_name = "my-course-videos"
preview_bucket_name = "my-course-videos-preview"

That's the entire backend for gated video. Auth + entitlement details are spelled out in Ch 3; the gate logic / paywall UX is Ch 4.

Byte-range support: why seeking just works

When you click halfway through a video, the browser sends:

Range: bytes=104857600-

…asking for "everything from byte 100 MiB onward." R2's get(key, { range }) supports this natively and returns the bytes plus a 206 Partial Content status. The Worker code above passes that through — so HTML5 seeking, scrubbing, and resume-where-you-left-off all work without any extra effort.

MP4 vs HLS — Which Should You Pick?

This is the second big choice. For a budget course site the answer is almost always MP4 — but it's worth knowing why and when HLS earns its complexity.

MP4 progressiveHLS (adaptive bitrate)
What it isOne fileOne .m3u8 playlist + many small .ts segments
Per-viewer bitrateWhatever you encoded (e.g. 3 Mbps)Adapts automatically (240p ↔ 1080p) based on viewer bandwidth
Server complexityZero — it's one fileYou serve hundreds of segments per video
Production complexityOne ffmpeg commandRender 3–5 quality ladders per video
Browser supportNative (HTML5 <video>)Native on Safari; needs hls.js elsewhere
R2 storage cost1 file per video3–5× as much (multiple ladders)

For a small course library where most viewers are on home WiFi, MP4 at a single sensible bitrate (3 Mbps for 1080p) plays great on every device and saves you a week of HLS pipeline work. Pre-render HLS only when you have hard data that 4G viewers are giving up on the higher-bitrate file. (And at that point, see "When to switch to Stream" below — Cloudflare has been waiting to do this for you.)

A Custom Domain Beats the r2.dev URL

By default R2 buckets get a *.r2.dev URL — fine for testing, ugly in production. Attaching a custom domain costs nothing and gets you:

In the R2 dashboard: bucket → Settings → Custom domains → Connect. Five clicks; takes about two minutes once DNS propagates.

When Cloudflare Stream Finally Wins

R2 + MP4 carries you a long way, but Stream's pitch isn't fake — there are real reasons to switch:

TriggerWhy Stream wins
You need true per-viewer adaptive bitrate without pre-rendering laddersStream does it automatically on upload
DRM (Widevine / FairPlay) is contractually requiredR2 can't do DRM; Stream can
You need per-viewer signed playback tokens with expiry, geo-fencing, IP bindingStream's signing API is one call; on R2 you'd build it in a Worker
Per-second watch-time analytics for every viewer × every videoStream has dashboards; on R2 you'd log + aggregate yourself
You're streaming at PB-scale and want Cloudflare to handle encoding capacityYou stop thinking about it

The cost ratio also narrows at scale — at very high egress, R2's "free bandwidth" stops being meaningful because Stream's bandwidth is also included. The breakeven point depends on your specific numbers, but a rough rule: under $50/month of equivalent Stream bill, R2 wins on price + simplicity; over a few hundred dollars, run the calculation.

Mental Model — Three Sentences

  1. R2 charges $0 for egress, so for a course site with normal viewership, hosting your video on R2 is 50–500× cheaper than Stream or any S3-style provider.
  2. Encode locally with ffmpeg -movflags +faststart, then either serve via a public R2 bucket (free content) or via a Worker proxy that checks JWT + entitlement before piping bytes (paid content) — both honour byte-range so seeking works.
  3. Graduate to Cloudflare Stream when a specific feature R2 can't do becomes blocking — adaptive bitrate without pre-rendering, DRM, signed-playback tokens, per-viewer analytics — and not before.

Try It Yourself (20 Minutes)

  1. Take any .mov and run the ffmpeg command above. Compare the original size to the encoded MP4 — typically 10–30× smaller.
  2. wrangler r2 object put it to a bucket, attach a custom domain, and drop the URL into an HTML <video> tag. Watch it play instantly thanks to +faststart.
  3. Try to seek in the player. Open DevTools → Network → notice the 206 Partial Content responses with Range: bytes=... request headers. That's byte-range working.
  4. Now make the bucket private and copy the Worker snippet above into a new wrangler init project. Add a fake verifyJWT that always returns a user. Confirm the video still plays through the Worker.
  5. Calculate your projected bill at your target viewership: storage × $0.015/GB + zero. That's the whole R2 line item.

Where This Lands in the Series

Video is the most expensive thing a course site delivers, and you've now got it costing $3 a month. Two pieces remain to ship the rest:

Ch 1: The Stack & The BillCh 3: Auth + Stripe Subscriptions
CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access