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:
- Account ID — visible in the right sidebar of any Cloudflare dashboard page
- API Token — create one at
dash.cloudflare.com/profile/api-tokenswith theCloudflare Stream: Editpermission
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.