Replace Cloudflare Stream with Ittybit and R2
Cloudflare Stream handles encoding, storage, and delivery as one opaque bundle. You upload a video, get a playback URL, pay per minute of stored and delivered video. It works — until you need to control encoding settings, choose your own codec, set a bitrate ladder, or serve video from a custom domain with your own access rules. Then you’re stuck.
Ittybit + R2 splits the problem into parts you own. Ittybit encodes the video and writes HLS output directly to your R2 bucket. A Worker serves the segments with signed URLs and whatever access control you want. You keep the files, you set the encoding parameters, and R2 storage costs a fraction of Stream’s per-minute pricing at volume.
What changes
| Cloudflare Stream | Ittybit + R2 | |
|---|---|---|
| Encoding | Automatic, no configuration | You choose codec, resolution, bitrate |
| Storage | Stream-managed, opaque | R2 bucket you own |
| Delivery | Stream subdomain | Workers on your custom domain |
| Access control | Signed tokens (Stream format) | Your own signed URLs via Workers |
| Pricing | Per minute stored + delivered | Ittybit task + R2 storage (no egress) |
| HLS manifest | Stream-generated, no customization | Standard .m3u8 in your bucket |
Connect R2 to Ittybit
R2 is S3-compatible. Create a connection so Ittybit can read source files from and write HLS output to your bucket.
const res = await fetch("https://api.ittybit.com/connections", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "s3",
name: "my-r2-bucket",
endpoint: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: "auto",
access_key_id: process.env.R2_ACCESS_KEY_ID,
secret_access_key: process.env.R2_SECRET_ACCESS_KEY,
}),
});
const connection = await res.json();
// connection.id -> "conn_abc123"curl -X POST https://api.ittybit.com/connections \
-H "Authorization: Bearer $ITTYBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "s3",
"name": "my-r2-bucket",
"endpoint": "https://<account-id>.r2.cloudflarestorage.com",
"region": "auto",
"access_key_id": "'$R2_ACCESS_KEY_ID'",
"secret_access_key": "'$R2_SECRET_ACCESS_KEY'"
}' Generate R2 API tokens in the Cloudflare dashboard under R2 > Manage R2 API Tokens. The token needs Object Read & Write permission on the bucket.
Encode HLS to R2
With Stream, you upload a file and get a playback ID. No control over the encoding. With Ittybit, you create an adaptive_video task and the HLS manifest + segments land in your R2 bucket at the path you specify.
const res = await fetch("https://api.ittybit.com/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: "s3://my-r2-bucket/uploads/raw.mov",
kind: "adaptive_video",
options: {
connection_id: "conn_abc123",
format: "hls",
},
output: "s3://my-r2-bucket/streams/raw/",
}),
});
const task = await res.json();curl -X POST https://api.ittybit.com/jobs \
-H "Authorization: Bearer $ITTYBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input": "s3://my-r2-bucket/uploads/raw.mov",
"kind": "adaptive_video",
"options": {
"connection_id": "conn_abc123",
"format": "hls"
},
"output": "s3://my-r2-bucket/streams/raw/"
}' Ittybit generates the rendition ladder automatically — multiple quality variants with a master playlist. The output lands at s3://my-r2-bucket/streams/raw/index.m3u8 with segment files alongside it.
Serve HLS from a Worker
Stream gives you a subdomain you can’t customize. With R2, you write a Worker that reads the HLS files from the bucket and serves them on your own domain. This is where you add signed URLs, token validation, or any other access control.
export interface Env {
MEDIA_BUCKET: R2Bucket;
SIGNING_SECRET: string;
}
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
// Validate the signed token
const token = url.searchParams.get('token');
if (!token || !(await verifyToken(token, env.SIGNING_SECRET))) {
return new Response('Unauthorized', { status: 403 });
}
// Strip the leading slash to get the R2 key
const key = `streams${url.pathname}`;
const object = await env.MEDIA_BUCKET.get(key);
if (!object) {
return new Response('Not found', { status: 404 });
}
// Set the right content type for HLS
const contentType = key.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
return new Response(object.body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
},
} satisfies ExportedHandler<Env>;
async function verifyToken(token: string, secret: string): Promise<boolean> {
const [payload, sig] = token.split('.');
if (!payload || !sig) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const expected = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
const expectedB64 = btoa(String.fromCharCode(...new Uint8Array(expected)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return sig === expectedB64;
}
Bind the Worker to your R2 bucket and custom domain in wrangler.toml:
name = "video-delivery"
route = "video.example.com/*"
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "my-r2-bucket"
# Set via: wrangler secret put SIGNING_SECRET
Your player points at https://video.example.com/raw/index.m3u8?token=<signed-token> instead of a Cloudflare Stream URL.
Generate signed playback URLs
Create tokens server-side so only authorized users can access a stream. The expiry and path scope are embedded in the token.
async function createPlaybackUrl(
videoPath: string,
secret: string,
expiresInSeconds: number = 3600,
): Promise<string> {
const payload = btoa(
JSON.stringify({
path: videoPath,
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
}),
)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `https://video.example.com/${videoPath}/index.m3u8?token=${payload}.${sigB64}`;
}
// Usage
const url = await createPlaybackUrl('raw', SIGNING_SECRET, 7200);
// https://video.example.com/raw/index.m3u8?token=eyJ...abc.xyz
What you can delete
Once the Ittybit pipeline is serving traffic:
| Cloudflare Stream resource | Why it existed |
|---|---|
| Uploaded videos | Stream’s internal copies |
| Stream subscriptions | Per-minute billing |
| Stream API tokens | Upload/playback management |
| Stream player embed codes | Replaced by your own player + Worker |
Keep your R2 bucket, Worker, and Ittybit connection — those are the new pipeline.
See also
- Process files from S3 — setting up S3-compatible connections
- Write output to S3 — writing processed files back to R2
- Create HLS streams — adaptive bitrate details
- Media processing with Cloudflare Workers and R2 — general R2 + Workers pipeline