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 |
| Encoding | Pre-encode locally with ffmpeg (free) | Included (uploads in, HLS out) |
| Adaptive bitrate | Pre-render multiple HLS ladders, or ship MP4 | Automatic per-viewer |
| Signed playback tokens | You build via Worker proxy + JWT | Built-in |
| DRM (Widevine / FairPlay) | Not supported | Available on top-tier plan |
| Free tier | 10 GB storage / month | None 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 item | R2 | Stream |
|---|---|---|
| Storage | 200 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.mp4Read it part by part:
| Flag | What it does |
|---|---|
-c:v libx264 | Video codec H.264 (universal browser support) |
-preset slow | Slower encode, smaller file. Worth the wait offline. |
-crf 22 | Quality target. 18 = visually lossless, 22 = small + great, 28 = small + ok. |
-c:a aac -b:a 128k | AAC audio at 128 kbps (every browser plays this) |
-movflags +faststart | Critical: 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 \
--remoteOr 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
doneTwo 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.mp4Drop 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.
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 progressive | HLS (adaptive bitrate) | |
|---|---|---|
| What it is | One file | One .m3u8 playlist + many small .ts segments |
| Per-viewer bitrate | Whatever you encoded (e.g. 3 Mbps) | Adapts automatically (240p ↔ 1080p) based on viewer bandwidth |
| Server complexity | Zero — it's one file | You serve hundreds of segments per video |
| Production complexity | One ffmpeg command | Render 3–5 quality ladders per video |
| Browser support | Native (HTML5 <video>) | Native on Safari; needs hls.js elsewhere |
| R2 storage cost | 1 file per video | 3–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:
- A clean URL:
videos.yourdomain.com/courses/...instead ofpub-abc123.r2.dev/... - Full Cloudflare CDN behaviour (cache rules, analytics)
- Better SEO and shareability
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:
| Trigger | Why Stream wins |
|---|---|
| You need true per-viewer adaptive bitrate without pre-rendering ladders | Stream does it automatically on upload |
| DRM (Widevine / FairPlay) is contractually required | R2 can't do DRM; Stream can |
| You need per-viewer signed playback tokens with expiry, geo-fencing, IP binding | Stream's signing API is one call; on R2 you'd build it in a Worker |
| Per-second watch-time analytics for every viewer × every video | Stream has dashboards; on R2 you'd log + aggregate yourself |
| You're streaming at PB-scale and want Cloudflare to handle encoding capacity | You 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
- 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.
- 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. - 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)
- Take any
.movand run theffmpegcommand above. Compare the original size to the encoded MP4 — typically 10–30× smaller. wrangler r2 object putit to a bucket, attach a custom domain, and drop the URL into an HTML<video>tag. Watch it play instantly thanks to+faststart.- 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. - Now make the bucket private and copy the Worker snippet above into a new
wrangler initproject. Add a fakeverifyJWTthat always returns a user. Confirm the video still plays through the Worker. - 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 3: Auth + Stripe — who is this viewer, and what have they paid for? Google OAuth in a Worker, JWT session cookies, Stripe Payment Links, and the webhook that writes "this user is now subscribed" into D1.
- Ch 4: The Paywall — the server-side gate logic (the
ifstatements in Figure 1) for videos and the SEO-safe count-based article paywall that keeps content indexed.
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