# Replace Cloudflare Stream with Ittybit and R2

Migrate from Cloudflare Stream to Ittybit for full control over encoding, storage, and delivery

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.

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
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"
```

```bash
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'"
  }'
```

</CodeGroup>

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.

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
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();
```

```bash
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/"
  }'
```

</CodeGroup>

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.

```typescript
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`:

```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.

```typescript
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](/guides/process-files-from-s3) -- setting up S3-compatible connections
- [Write output to S3](/guides/write-output-to-s3) -- writing processed files back to R2
- [Create HLS streams](/guides/create-hls-streams) -- adaptive bitrate details
- [Media processing with Cloudflare Workers and R2](/guides/media-processing-with-cloudflare-workers-and-r2) -- general R2 + Workers pipeline