Ditch the FFmpeg Nightmare: Cloudflare Stream as Your Self-Hosted Video Backend

If you’ve ever tried to build a proper video pipeline on your own server, you know the drill. You throw FFmpeg at a raw MP4, wait 20 minutes while your CPU screams, produce five different resolution variants, store them somewhere, serve them over Nginx with byte-range support, and then realize your $10/month VPS is completely throttled whenever someone actually watches a video.

PeerTube is great for fully decentralized setups, but it still transcodes on your machine. Nextcloud will store your videos, but delivery is your problem. Rolling your own HLS pipeline with FFmpeg and S3-compatible storage works, but you’re now maintaining a small media company’s worth of infrastructure.

Cloudflare Stream is a different bet: you give up the self-hosting of the video processing layer, keep full programmatic control via API, and get a global CDN with adaptive bitrate streaming baked in — for roughly $5 per 1,000 minutes of stored video. For a blog, portfolio, or internal tool with moderate video content, the math is almost always better than running dedicated transcoding hardware.

This article covers the practical integration path: uploading videos, handling transcoding webhooks, serving adaptive streams, and locking things down with signed URLs.

Official docs: https://developers.cloudflare.com/stream/


Why Cloudflare Stream Fits the Self-Hoster Model

The self-hoster instinct is to own everything. That’s usually right. But video transcoding is one of those domains where the infrastructure cost per unit of feature delivered is genuinely punishing.

Consider what "proper" video self-hosting requires:

  • Transcoding to HLS or DASH with multiple quality levels (360p, 720p, 1080p, maybe 4K)
  • Keyframe alignment across renditions so adaptive switches don’t stutter
  • Thumbnail generation at multiple timestamps
  • CDN or at minimum edge caching to keep origin bandwidth sane
  • Byte-range request handling for seeking

Cloudflare Stream handles all of that. Your server’s only job is to accept uploads and serve your application. The video content lives on Cloudflare’s edge, transcoded and ready.


Account Setup and Credentials

You need a Cloudflare account with Stream enabled. The free tier doesn’t include Stream — you’ll need to turn it on in the dashboard under Stream. The pricing is usage-based: ~$5/1,000 minutes stored and ~$1/1,000 minutes delivered.

Grab two things from the dashboard:

  1. Account ID — visible in the right sidebar of any Cloudflare dashboard page
  2. API Token — create one at dash.cloudflare.com/profile/api-tokens with the Cloudflare Stream: Edit permission

Store these in environment variables. Never hardcode them.

export CF_ACCOUNT_ID="your_account_id_here"
export CF_API_TOKEN="your_api_token_here"

Uploading a Video via the API

There are three upload methods. Choose based on your architecture:

Method When to use
Single-step upload Files under 200 MB, server-side
TUS resumable upload Large files, or when reliability matters
Direct Creator Upload Browser uploads that bypass your server entirely

Single-Step Upload (Server-Side)

Good for scripts, cron jobs, or backend pipelines.

#!/usr/bin/env bash
# upload_video.sh — upload a local video to Cloudflare Stream

set -euo pipefail

VIDEO_FILE="${1:?Usage: upload_video.sh <file>}"
VIDEO_NAME="${2:-$(basename "$VIDEO_FILE")}"

RESPONSE=$(curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/stream" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -F "file=@${VIDEO_FILE}" \
  -F "meta={\"name\":\"${VIDEO_NAME}\"}")

VIDEO_UID=$(echo "$RESPONSE" | jq -r '.result.uid')
echo "Uploaded. Video UID: ${VIDEO_UID}"
echo "Status: $(echo "$RESPONSE" | jq -r '.result.status.state')"

The response gives you a uid — that’s the identifier you’ll use for everything else. Keep it.

The status.state will be inprogress immediately after upload. Cloudflare needs time to transcode. You’ll see it move through downloading → queued → inprogress → ready.

TUS Resumable Upload

For anything over ~100 MB or where network reliability is a concern, use TUS. Cloudflare Stream’s TUS endpoint is https://api.cloudflare.com/client/v4/accounts/{account_id}/stream.

The tus-py-client library handles this cleanly in Python:

import tusclient.client as tus

# TUS upload to Cloudflare Stream
def upload_video_tus(file_path: str, video_name: str) -> str:
    client = tus.TusClient(
        f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/stream",
        headers={
            "Authorization": f"Bearer {CF_API_TOKEN}",
            # Required by Cloudflare — tells them to schedule for transcoding
            "Tus-Resumable": "1.0.0",
        },
    )

    uploader = client.uploader(
        file_path,
        metadata={
            "name": video_name,
            "requiresignedurls": "true",  # optional — lock it down
        },
        chunk_size=52428800,  # 50 MB chunks
    )
    uploader.upload()

    # Cloudflare returns the video UID in the Location header
    video_uid = uploader.url.split("/")[-1]
    return video_uid

Direct Creator Upload (Browser → Cloudflare, No Server Relay)

This is the elegant option when you’re building a web app. Your backend generates a one-time upload URL, gives it to the browser, and the browser uploads directly to Cloudflare. Your server never touches the video bytes.

# Generate a direct upload URL (server-side)
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/stream/direct_upload" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "maxDurationSeconds": 3600,
    "expiry": "2026-05-25T23:59:00Z",
    "meta": {"name": "user-upload"},
    "requireSignedURLs": true
  }'

Response includes uploadURL and uid. Pass uploadURL to your frontend JavaScript. The browser uploads to that URL using the TUS protocol — the tus-js-client library handles it in about ten lines.


Webhooks: Know When Transcoding Is Done

Polling the API every 10 seconds is fine for a script. For a production app, use webhooks.

Set up a webhook URL in your Cloudflare Stream dashboard under Settings → Webhooks. Cloudflare will POST to your endpoint when a video’s state changes.

Here’s a minimal Flask handler:

from flask import Flask, request, abort
import hmac, hashlib

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_from_dashboard"

@app.route("/webhook/cloudflare-stream", methods=["POST"])
def stream_webhook():
    # Verify the signature Cloudflare includes in the header
    sig = request.headers.get("Webhook-Signature", "")
    body = request.get_data()

    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(f"sha256={expected}", sig):
        abort(403)

    event = request.json
    video_uid = event["uid"]
    state = event["status"]["state"]

    if state == "ready":
        # Video is transcoded — update your database
        mark_video_ready(video_uid)
    elif state == "error":
        # Log it, alert, retry logic goes here
        handle_transcoding_error(video_uid, event["status"]["errorReasonCode"])

    return "", 200

Gotcha: Cloudflare may send the same webhook event more than once. Make your handler idempotent — updating a status = 'ready' row twice should be harmless.


Playback: Embedding and Custom Players

Once a video is ready, you have two playback options.

Cloudflare’s Built-in Player

The simplest path — an iframe embed:

<iframe
  src="https://iframe.videodelivery.net/{VIDEO_UID}"
  style="border: none; position: absolute; top: 0; height: 100%; width: 100%;"
  allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
  allowfullscreen>
</iframe>

It handles adaptive bitrate switching, captions, and mobile playback out of the box.

Custom Player with HLS.js

When you need full control over the player UI, grab the HLS manifest URL and feed it to hls.js or Video.js:

import Hls from "hls.js";

const videoEl = document.getElementById("my-video");
// The HLS manifest URL pattern for Cloudflare Stream
const hlsUrl = `https://videodelivery.net/${VIDEO_UID}/manifest/video.m3u8`;

if (Hls.isSupported()) {
  const hls = new Hls();
  hls.loadSource(hlsUrl);
  hls.attachMedia(videoEl);
} else if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
  // Safari has native HLS support
  videoEl.src = hlsUrl;
}

The manifest URL is the same regardless of resolution — Cloudflare handles the adaptive bitrate logic based on the viewer’s bandwidth.


Signed URLs: Locking Down Private Video

By default, any video you upload is publicly playable. For private content — paid courses, internal documentation, user-uploaded files — you need signed URLs.

When uploading, set requireSignedURLs: true. Then, every playback request must include a signed token. Tokens are JWTs signed with a key pair you generate via the API.

Step 1: Generate a Key Pair

# Create a signing key — store the response, you'll need uid and pem
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/stream/keys" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  | jq '.result'

Save the uid (key ID) and pem (private key, base64-encoded). The private key is shown only once.

Step 2: Issue Tokens in Your Backend

import time, base64, json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

def sign_stream_token(
    video_uid: str,
    key_id: str,
    private_key_pem_b64: str,
    expires_in: int = 3600,
) -> str:
    private_key = serialization.load_pem_private_key(
        base64.b64decode(private_key_pem_b64),
        password=None,
        backend=default_backend(),
    )

    exp = int(time.time()) + expires_in
    # Header and payload as per Cloudflare's signed URL spec
    header = base64.urlsafe_b64encode(
        json.dumps({"alg": "RS256", "kid": key_id}).encode()
    ).rstrip(b"=")

    payload = base64.urlsafe_b64encode(
        json.dumps({"sub": video_uid, "kid": key_id, "exp": exp}).encode()
    ).rstrip(b"=")

    signing_input = header + b"." + payload
    signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
    sig_b64 = base64.urlsafe_b64encode(signature).rstrip(b"=")

    return (signing_input + b"." + sig_b64).decode()

Use the token like this:

https://videodelivery.net/{VIDEO_UID}/manifest/video.m3u8?token={SIGNED_TOKEN}

Gotcha: Tokens have an expiry. If a user loads your page, walks away for an hour, then hits play — the token may be expired. Generate tokens server-side on each page load (or via a short-lived API call), not once at account setup time. A 2-hour expiry with refresh-on-seek is a reasonable default.


Thumbnails and Animated Previews

Cloudflare generates thumbnails automatically. The URL pattern is dead simple:

# Thumbnail at the 10-second mark, 640px wide
https://videodelivery.net/{VIDEO_UID}/thumbnails/thumbnail.jpg?time=10s&width=640

# Animated GIF preview (first 3 seconds)
https://videodelivery.net/{VIDEO_UID}/thumbnails/thumbnail.gif?duration=3s&width=400

No API call needed — these are generated on demand and cached. Use them directly in <img> tags for video cards in your UI.


Gotchas Worth Knowing

Upload size limit. Cloudflare Stream has a maximum video duration of 6 hours per file, and individual files can’t exceed 30 GB. Fine for almost anything, but worth noting.

Transcoding isn’t instant. A 30-minute 4K video can take 15-20 minutes to transcode fully. Design your app to handle the inprogress state gracefully — show a "processing" state to users rather than a broken player.

Egress isn’t free. Stream pricing includes delivery minutes, not raw bandwidth. Each minute a viewer watches counts against your delivery quota. If you’re embedding on a high-traffic page, audit your viewer analytics before assuming the cost is trivial.

No self-hosted option. This is Cloudflare’s SaaS, full stop. Your video bytes live on their infrastructure. If your content is subject to specific data residency requirements, Stream may not be the right fit. Cloudflare does offer regional data controls in their Enterprise tier.

API rate limits. The default API rate limit is 1,200 requests per five minutes per account. For bulk upload jobs, add a small sleep between requests or batch your metadata queries.

Webhook reliability. Cloudflare’s webhooks can occasionally be delayed under load. Don’t make your user experience dependent on sub-second webhook delivery. A fallback polling mechanism on the backend (checking status every minute for new uploads) is cheap insurance.


Production-Ready: A Minimal Upload Service

Here’s a pattern that works well in production — a thin upload service that accepts a file, kicks off the Cloudflare upload, stores the UID, and responds asynchronously via webhook.

# docker-compose.yml
version: "3.9"
services:
  video-api:
    build: .
    environment:
      - CF_ACCOUNT_ID=${CF_ACCOUNT_ID}
      - CF_API_TOKEN=${CF_API_TOKEN}
      - WEBHOOK_SECRET=${WEBHOOK_SECRET}
      - DATABASE_URL=postgresql://app:pass@db:5432/videos
    ports:
      - "8000:8000"
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: videos
      POSTGRES_USER: app
      POSTGRES_PASSWORD: pass
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

The database table you need is minimal:

CREATE TABLE videos (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    cf_uid      TEXT UNIQUE,           -- Cloudflare Stream UID
    name        TEXT NOT NULL,
    status      TEXT DEFAULT 'pending', -- pending | processing | ready | error
    duration    FLOAT,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    ready_at    TIMESTAMPTZ
);

Your upload endpoint creates the database row immediately (with status = 'pending'), fires off the Cloudflare upload in a background task, and returns the internal id to the caller. The webhook handler updates status and duration when transcoding completes. The player endpoint checks status = 'ready' before issuing a signed token.

This pattern means your API is never blocked waiting on Cloudflare, and your UI always has a consistent source of truth in your own database.


Cost Reality Check

Let’s be concrete. Say you’re running a technical blog with 50 video tutorials, each averaging 15 minutes long.

  • Storage: 50 × 15 = 750 minutes × $5/1,000 = $3.75/month
  • Delivery: If each video gets 200 views per month: 750 × 200 = 150,000 minutes × $1/1,000 = $150/month

The delivery cost is where it scales. For a high-traffic site, Cloudflare Stream’s per-minute pricing becomes expensive compared to hosting video on R2 (Cloudflare’s S3-compatible object storage) and serving raw MP4s. But raw MP4 serving means no adaptive streaming, no seeking optimization, no automatic transcoding. The trade-off is real.

For moderate traffic (under 50,000 delivery minutes/month), Stream is hard to beat on total cost of ownership when you factor in the engineering time to build an equivalent pipeline.


When to Look Elsewhere

Cloudflare Stream is a good fit for: small to mid-size content libraries, SaaS products with user-uploaded video, internal tooling, blogs, and documentation sites.

It’s probably not the right fit if you need:

  • Full data sovereignty (look at self-hosted MinIO + FFmpeg workers)
  • Live streaming at scale (Mux or AWS IVS handle this better)
  • Content that’s at high risk of Cloudflare’s acceptable use enforcement
  • Extremely high delivery volumes where per-minute pricing outweighs infrastructure costs

For purely self-hosted alternatives, AVideo (formerly YouPHPTube) or PeerTube give you FFmpeg-based transcoding on your own hardware. They’re operationally heavier but genuinely yours. The choice comes down to whether you want to own the complexity or outsource it.


Cloudflare Stream’s API surface is clean, the documentation is accurate, and the reliability is what you’d expect from Cloudflare’s infrastructure. For self-hosters who want professional-grade video delivery without managing a transcoding cluster, it’s genuinely one of the better pragmatic compromises available today.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646