Stream + Images: edge media pipelines on Cloudflare

Cloudflare's 3 media products: Stream (video, HLS/DASH), Images (upload-transform-deliver), and Image Resizing / cf.image. Pipelines, pricing, and when to pick which.

· 8 min read · Đọc bản tiếng Việt
Cloudflare edge media pipelines: Stream for video (upload → multi-bitrate transcode → HLS/DASH), Images for the upload-transform-deliver lifecycle, and Image Resizing/cf.image for R2/S3/web origins plus dynamic OG images

TL;DR

Cloudflare has three complementary media products:

  • Stream: end-to-end video. Upload → multi-bitrate transcode → HLS/DASH → player embed. Priced by minutes stored + minutes delivered.
  • Images: full photo lifecycle. Upload → variant config → deliver via imagedelivery.net. Subscription $5/100k stored + $1/100k delivered.
  • Image Resizing on Zone or Worker cf.image: transform images from any origin (R2, S3, web). Pay-per-use $0.50/1k unique transforms, CDN cache hits free.

Main thesis:

Not every use case needs the Images product. A tech blog with covers + dynamic OG images → R2 + Image Resizing is enough and cheaper. An e-commerce site with 100k product photos + multiple variants → the Images product subscription earns its keep. Any video volume → Stream is the only reasonable option (self-hosting FFmpeg is a rabbit hole).

This post covers: the 2 pipelines (Stream, Images) in detail, 3 ways to transform images with trade-offs, responsive + lazy patterns, real dynamic OG images, signed URLs, and when each product is correct.

This post closes Block 4 (AI + Media). Block 5 (Production) opens with Part 17 on observability.


Who this is for

  • Developers building apps that upload / serve images or video.
  • Blog owners who want automatic cover + OG image optimization.
  • E-commerce teams needing responsive product photos at multiple sizes.
  • Anyone self-hosting FFmpeg who wants out.

Recommended prerequisites: Part 7 (R2), Part 13 (Workers AI for Flux).

By the end of this post you will:

  • Upload + serve video through Stream in under 30 minutes.
  • Know when to pick Images vs R2 + transform.
  • Implement responsive images with srcset + format=auto.
  • Generate dynamic OG images from a Worker.

What this post isn’t about

  • Live streaming deep-dive: Stream Live exists but has a different flow (WebRTC/RTMP ingest). This post focuses on VOD.
  • Video editor / post-production: DAW, NLE aren’t Cloudflare’s domain.
  • AI image generation: Flux/SDXL was covered in Part 13. This post focuses on transform + deliver, not generation.

Big picture

Media pipeline: Stream (tus upload → multi-bitrate transcode → HLS/DASH package → CDN 330+ PoPs → player embed). Images (multipart POST or URL pull → Cloudflare Images store → variant transform → CDN cache → client img). Alternative: R2 + Image Resizing on Zone or Worker cf.image options for flexible control.


Cloudflare Stream: video

Video is a rabbit hole if you self-host: transcode, storage, ABR, DRM, CDN, player. Stream packages all of it into a single subscription.

Upload

Direct creator upload (client-to-Cloudflare, bypassing your Worker):

// Worker creates the upload URL
async function getUploadURL(request: Request, env: Env): Promise<Response> {
  const r = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/direct_upload`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.STREAM_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        maxDurationSeconds: 3600,
        expiry: new Date(Date.now() + 3600_000).toISOString(),
      }),
    }
  );

  const { result } = await r.json();
  return Response.json({ uploadURL: result.uploadURL, uid: result.uid });
}

Client uses tus (resumable upload protocol):

import * as tus from "tus-js-client";

const file = document.querySelector("input[type=file]").files[0];
const upload = new tus.Upload(file, {
  endpoint: uploadURL,  // from Worker
  chunkSize: 50 * 1024 * 1024,  // 50MB
  onProgress: (sent, total) => {
    console.log(`${(sent / total * 100).toFixed(1)}%`);
  },
  onSuccess: () => console.log("Done"),
});
upload.start();

Resumable: network drops, upload resumes exactly where it left off. Critical for mobile / unstable connections.

Transcode

Automatic. Cloudflare transcodes the uploaded video into multiple renditions:

Input: 4K MP4 upload
Output:
  → 240p (low bandwidth)
  → 360p
  → 480p
  → 720p
  → 1080p
  → (no transcode above source)

ABR (Adaptive Bitrate) — the player picks a rendition matching the user’s bandwidth.

Transcode time: roughly real-time. A 10-minute video = ~10-minute transcode.

Package

HLS (Apple ecosystem + modern browsers) + DASH (Android + fallback). Auto-generated.

Manifest URL:

https://customer-<code>.cloudflarestream.com/<uid>/manifest/video.m3u8
https://customer-<code>.cloudflarestream.com/<uid>/manifest/video.mpd

Embed

Option 1: Cloudflare player iframe.

<iframe
  src="https://customer-<code>.cloudflarestream.com/<uid>/iframe"
  style="border: none; aspect-ratio: 16/9; width: 100%;"
  allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
  allowfullscreen>
</iframe>

Option 2: HLS.js / Video.js custom player.

<video id="player" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
const video = document.getElementById("player");
if (Hls.isSupported()) {
  const hls = new Hls();
  hls.loadSource("https://customer-<code>.cloudflarestream.com/<uid>/manifest/video.m3u8");
  hls.attachMedia(video);
}
</script>

Signed URL (optional)

Protect private videos:

async function getSignedURL(uid: string, env: Env): Promise<string> {
  const r = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/stream/${uid}/token`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.STREAM_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        exp: Math.floor(Date.now() / 1000) + 3600,  // 1h
      }),
    }
  );

  const { result } = await r.json();
  return `https://customer-<code>.cloudflarestream.com/${result.token}/manifest/video.m3u8`;
}

Token expires in 1h. Users can’t share the URL forever. Enough for paid content, course platforms.

Stream pricing

  • Storage: $5 per 1000 minutes stored per month.
  • Delivery: $1 per 1000 minutes delivered.

Example: 100 videos, avg 10 minutes = 1000 minutes stored = $5/month. 10k views/month, avg 5 minutes watched = 50k minutes delivered = $50/month. Total ~$55/month.

Compared to AWS + MediaConvert + CloudFront: similar or cheaper, but far less operational effort.


Cloudflare Images: photo lifecycle

Images targets apps that upload + serve many photos (UGC, e-comm, photography).

Upload

Three ways:

A. Direct upload (client → CF):

// Worker creates the upload URL
async function getImageUploadURL(env: Env) {
  const r = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${env.IMAGES_API_TOKEN}` },
    }
  );
  const { result } = await r.json();
  return { uploadURL: result.uploadURL, id: result.id };
}

Client POSTs the file:

const formData = new FormData();
formData.append("file", fileInput.files[0]);
await fetch(uploadURL, { method: "POST", body: formData });

B. Server-side upload:

const formData = new FormData();
formData.append("file", fileBlob);

await fetch(
  `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
    body: formData,
  }
);

C. Pull from a public URL:

await fetch(
  `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url: "https://example.com/photo.jpg" }),
  }
);

Variants

Instead of per-request transforms, Images uses pre-configured “variants”.

Dashboard → Images → Variants:

variant: "thumb"
  width: 200
  height: 200
  fit: cover
  format: auto

variant: "hero"
  width: 1920
  height: 1080
  fit: contain
  format: auto
  quality: 85

URL:

https://imagedelivery.net/<account-hash>/<image-id>/thumb
https://imagedelivery.net/<account-hash>/<image-id>/hero

No custom params inside the URL (unlike Image Resizing). You must define variants first.

Flexible variants

Enable the option in dashboard → URLs can include inline params:

https://imagedelivery.net/<account-hash>/<image-id>/w=800,h=600,fit=cover,format=auto

Flexible but no schema validation.

Signed URLs

Protect private images:

const SIGNING_KEY = env.IMAGES_SIGNING_KEY;

function signImageURL(imageId: string, variant: string, expiry: number) {
  const url = `https://imagedelivery.net/<hash>/${imageId}/${variant}?exp=${expiry}`;
  const hmac = crypto.subtle.sign(
    "HMAC",
    importKey(SIGNING_KEY),
    new TextEncoder().encode(url)
  );
  return `${url}&sig=${toHex(hmac)}`;
}

Images pricing

  • Storage: $5 per 100k images stored per month.
  • Delivery: $1 per 100k images delivered.
  • Transformation: included (not billed separately).

Simple: $10/month for 100k images stored + 100k views. Scales linearly.


Image Resizing on Zone (no Images product needed)

3 transform options: Cloudflare Images (upload + variant), Image Resizing on Zone (URL path /cdn-cgi/image/...), Worker cf.image (programmatic). Trade-off: managed vs pay-per-use vs flexible.

If your images already live in R2, S3, or a web URL, you don’t need the Images product. Enable Image Resizing on the zone:

Dashboard → Speed → Optimization → Image Resizing → Enable

URL pattern:

https://<zone>/cdn-cgi/image/width=800,format=auto/<original-path>

Example:

<!-- Original -->
https://cloudsecop.net/images/cover.jpg (2000×1500 PNG, 2MB)

<!-- Transformed -->
https://cloudsecop.net/cdn-cgi/image/width=800,format=auto/images/cover.jpg
(800×600 WebP, ~80KB — 25x smaller)

Options

  • width, height: resize.
  • fit: scale-down, contain, cover, crop, pad.
  • format: auto (WebP/AVIF per browser), webp, avif, json.
  • quality: 1-100 (default 85).
  • gravity: auto, left, right, top, bottom, center.
  • background: color for pad.
  • blur, sharpen, brightness, contrast: filters.
  • trim: crop borders.
  • dpr: device pixel ratio.

Requirements

  • The zone must have a custom domain.
  • Cloudflare workers.dev doesn’t support this (must go through a zone).
  • Enable in dashboard.

Pricing

  • $0.50 per 1000 unique transformations.
  • Cache hits are free (standard CDN rules).

Example: 100 posts × 3 sizes (1x, 2x, 3x) × 2 formats (WebP, AVIF) = 600 unique transforms. 100k views/month with 90% cache hit rate = 10k misses = only ~600 actual unique transforms (repeat misses for the same variant still count once). Cost ~$0.30/month.


Worker cf.image: programmatic

Need custom logic (auth, dynamic params, watermarks)? Worker fetch with cf.image:

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const imagePath = url.pathname.slice(1);  // trim leading /
    const width = parseInt(url.searchParams.get("w") || "800");

    // Auth check
    if (url.searchParams.get("private") === "true") {
      const user = await authenticate(request);
      if (!user) return new Response("Unauthorized", { status: 401 });
    }

    // Fetch with image resize
    const imageRequest = new Request(`https://origin.com/${imagePath}`, request);
    return fetch(imageRequest, {
      cf: {
        image: {
          width,
          format: "auto",
          fit: "scale-down",
          quality: 85,
        },
      },
    });
  },
};

Strongest when:

  • Origin is protected (R2 private bucket).
  • Per-user auth.
  • Dynamic watermarks.
  • You want to log each view into Analytics Engine.

Pattern: responsive images

HTML <img srcset> lets the browser pick the right size:

<img
  src="https://cloudsecop.net/cdn-cgi/image/width=800,format=auto/images/cover.jpg"
  srcset="
    https://cloudsecop.net/cdn-cgi/image/width=400,format=auto/images/cover.jpg 400w,
    https://cloudsecop.net/cdn-cgi/image/width=800,format=auto/images/cover.jpg 800w,
    https://cloudsecop.net/cdn-cgi/image/width=1600,format=auto/images/cover.jpg 1600w
  "
  sizes="(max-width: 640px) 100vw, 800px"
  alt="..."
  loading="lazy"
/>

The browser picks:

  • Mobile 400px viewport → loads 400w.
  • Retina desktop at 1600px → loads 1600w.

loading="lazy": the browser defers loading below-fold images. Free perf win.

With Astro

Astro has an <Image /> component, but the transform URL needs customization. Helper:

---
// src/components/CFImage.astro
interface Props {
  src: string;
  alt: string;
  widths?: number[];
  sizes?: string;
  loading?: "lazy" | "eager";
}

const {
  src,
  alt,
  widths = [400, 800, 1200, 1600],
  sizes = "(max-width: 640px) 100vw, 800px",
  loading = "lazy",
} = Astro.props;

const srcset = widths.map((w) => 
  `/cdn-cgi/image/width=${w},format=auto,quality=85${src} ${w}w`
).join(", ");

const defaultSrc = `/cdn-cgi/image/width=${widths[Math.floor(widths.length / 2)]},format=auto${src}`;
---

<img src={defaultSrc} srcset={srcset} sizes={sizes} alt={alt} loading={loading} />

Use:

<CFImage src="/images/cover.jpg" alt="Cover" />

Pattern: dynamic OG images

OG images (1200×630) show when you share links on Twitter, LinkedIn, Slack. Static files for 100 posts = boring. Dynamic OG from a Worker:

Method 1: SVG to PNG

// worker/og.ts
export async function generateOG(title: string, env: Env) {
  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
      <rect width="1200" height="630" fill="#0f172a"/>
      <text x="60" y="120" fill="#f48120" font-family="system-ui" font-size="32" font-weight="700">
        CloudSecOp
      </text>
      <text x="60" y="200" fill="#fff" font-family="system-ui" font-size="54" font-weight="700">
        ${escapeXml(title.slice(0, 60))}
      </text>
    </svg>
  `;

  // Convert SVG → PNG through Worker cf.image
  const pngResponse = await fetch(
    `data:image/svg+xml;base64,${btoa(svg)}`,
    {
      cf: {
        image: {
          width: 1200,
          height: 630,
          format: "png",
        },
      },
    }
  );

  return pngResponse;
}

Trick: the Worker doesn’t execute the SVG → PNG conversion itself. Using cf.image with format: "png" pushes the conversion down to Cloudflare’s infrastructure.

Method 2: AI-generated with Flux

const image = await env.AI.run("@cf/black-forest-labs/flux-1-schnell", {
  prompt: `Minimal geometric cover for: "${title}". Dark theme, orange accent, editorial.`,
  num_steps: 4,
});

return new Response(image, { headers: { "Content-Type": "image/png" } });

Nicer than an SVG template, but more expensive (~$0.005/image). Aggressive cache to R2:

// Check cache first
const cached = await env.R2.get(`og/${slug}.png`);
if (cached) return new Response(cached.body, { headers: { "Content-Type": "image/png" } });

// Generate
const image = await env.AI.run(...);

// Cache
await env.R2.put(`og/${slug}.png`, image);

return new Response(image, { headers: { "Content-Type": "image/png" } });

Generate once, serve forever. 100 posts = ~$0.50 one-time cost.


Comparison: 3 image options

Use caseRecommendedPricing estimate (small scale)
E-comm with 10k+ product photos, lifecycle + variantsCloudflare Images~$10/month
Blog with 100 covers + OG, images in R2 or publicImage Resizing on Zone~$0.50/month
UGC photo upload, avatars, multi-sizeCloudflare Images~$10/month
Auth-gated images (paid content)Worker cf.image + R2 private~$1-2/month
Static marketing imagesImage Resizing on Zone~$0.10/month

Main decisions:

  • Need an upload workflow? → Images product or a Worker API that writes to R2.
  • Public images on a zone? → Image Resizing on Zone is enough and cheap.
  • Complex custom logic? → Worker cf.image.

Gotchas

① Image Resizing doesn’t work on workers.dev

*.workers.dev isn’t a zone → no /cdn-cgi/image/. You need a custom domain. Dev workflow: configure a custom domain or test in production.

② Cache key with query params

/cdn-cgi/image/width=800/images/cover.jpg?v=1  → cache key 1
/cdn-cgi/image/width=800/images/cover.jpg?v=2  → cache key 2

Cache-bust with a query param. But don’t use random ?t=Date.now() → miss on every request.

③ Format auto depends on the Accept header

format=auto returns:

  • AVIF if the browser supports it (Chrome 85+, Safari 16+, Firefox 93+).
  • WebP if not.
  • JPEG fallback.

Test with curl:

curl -H "Accept: image/avif,image/webp" https://... -o test.avif

④ Signed URL formats

Images: HMAC in a ?sig= param. Stream: token in the path.

Not interchangeable. Read the docs carefully before coding.

⑤ Stream upload deadlines

Direct upload URLs have an expiry. Users uploading past the deadline = 410 Gone. Set the expiry long enough for slow connections (1h is reasonable).

⑥ MP4 download from Stream

By default Stream serves HLS/DASH, not MP4 downloads. Enable downloadable: true when creating the video:

{ "requireSignedURLs": false, "allowedOrigins": [...], "downloadable": true }

MP4 download URL: https://videodelivery.net/<uid>/downloads/default.mp4

⑦ Images variant cache

Updating a variant config → old URLs keep serving the old cached version until TTL expires (or you purge). Use a new variant name for breaking changes (hero-v2 instead of editing hero).

⑧ Transparent PNG → JPEG

format=auto on PNG with alpha → if the browser only supports JPEG, the alpha channel is lost (fills with black/white). Keep PNG explicit:

/cdn-cgi/image/width=800,format=png/...

⑨ Stream storage isn’t free

Delete videos when you don’t need them. A 1GB video at 8Mbps = 1 minute → $0.005/month/video. 1000 forgotten videos = $5/month of invisible cost.


Observability

Dashboard: Stream and Images have per-product analytics:

  • Stream: video minutes delivered, bandwidth, viewer country.
  • Images: transformation count, delivery count, cache hit rate.

Log via Logpush → R2 for long-term storage (every access request).

Alerts:

  • Transformation count spike (DDoS abuse via /cdn-cgi/image/).
  • Storage cost spike (forgotten uploads).
  • Bandwidth spike.

Production checklist

  • Stream direct upload via a Worker (don’t expose the token client-side).
  • Images upload through a direct upload URL (don’t pass files through the Worker — wastes CPU).
  • Signed URLs for private content (Stream: token, Images: HMAC).
  • Responsive images: srcset + sizes + loading="lazy".
  • format=auto so browsers get AVIF/WebP.
  • Dynamic OG images cached in R2, not generated per-request.
  • Stable variant naming, don’t modify existing variants (breaking change).
  • Enable Image Resizing on a custom domain (workers.dev not supported).
  • Monitor cost: storage, transformations, delivery.
  • Clean up orphaned media (videos/images with no references).
  • Cache headers Cache-Control: public, max-age=31536000, immutable for versioned assets.

Wrap-up

Stream is the only reasonable answer for video. The Images product fits UGC/e-comm apps with upload workflows. For everything else — blogs, content sites, marketing — Image Resizing on Zone or Worker cf.image is cheaper and flexible enough.

Block 4 (AI + Media) closes with 4 parts: Workers AI, Vectorize RAG, Durable Objects, Stream + Images. The Cloudflare platform is now nearly full-stack.

Block 5 (Production) opens with Part 17: Observability with Logs + Tail Workers + Analytics Engine — how to know a Worker is working, debug production, and alert on errors.


References