Replace Cloudflare Stream with Ittybit and R2

View Markdown

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 StreamIttybit + R2
EncodingAutomatic, no configurationYou choose codec, resolution, bitrate
StorageStream-managed, opaqueR2 bucket you own
DeliveryStream subdomainWorkers on your custom domain
Access controlSigned tokens (Stream format)Your own signed URLs via Workers
PricingPer minute stored + deliveredIttybit task + R2 storage (no egress)
HLS manifestStream-generated, no customizationStandard .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 resourceWhy it existed
Uploaded videosStream’s internal copies
Stream subscriptionsPer-minute billing
Stream API tokensUpload/playback management
Stream player embed codesReplaced by your own player + Worker

Keep your R2 bucket, Worker, and Ittybit connection — those are the new pipeline.

See also