Building a Jellyfin CDN with Cloudflare R2 and Workers: Global Media Delivery (2026)

Building a Jellyfin CDN with Cloudflare R2 and Workers: Global Media Delivery (2026)

Building a Jellyfin CDN with Cloudflare R2: Global Media Delivery (2026)

Your Jellyfin server is in Paris. Your friend in Tokyo experiences 3 seconds of buffering at the start of every video because the data travels 10,000 km. A CDN (Content Delivery Network) solves this by placing your content closer to your users.

Cloudflare R2 is an S3-compatible object storage with zero egress fees. Combined with Cloudflare Workers (edge compute), you can build a global media delivery system that serves your Jellyfin library from the nearest Cloudflare edge location to each user.

This is an advanced architecture. It is not for everyone. But for admins with users on multiple continents, it eliminates geographic latency entirely.


How It Works

User in Tokyo requests a movie
  |
  v
[Cloudflare Worker (Tokyo edge)] -- checks auth, serves from R2
  |
  v
[Cloudflare R2 (global)] -- stores the actual media file
  |
  v
User receives data from Tokyo edge (low latency)

Meanwhile, your Jellyfin server (in Paris) handles:

  • Library browsing and metadata
  • User authentication
  • Watch state tracking
  • Transcoding (if needed)

The media FILES are served from R2 via Workers, not from your home server. Your home upload speed is no longer the bottleneck for remote users.


The Architecture

Component 1: Cloudflare R2 (Storage)

R2 is S3-compatible object storage with a critical advantage: zero egress fees. You pay only for storage ($0.015/GB/month) and operations (reads/writes), not for bandwidth.

For a 5 TB media library:

  • Storage cost: 5,000 GB x $0.015 = $75/month
  • Egress: $0 (unlimited bandwidth)

Compare to AWS S3: 5 TB storage ($115/month) + egress at $0.09/GB = potentially thousands per month.

Component 2: Cloudflare Workers (Edge Logic)

Workers run JavaScript/TypeScript at Cloudflare edge locations worldwide (~300 cities). They handle:

  • Authentication (verify the request is from a valid Jellyfin user)
  • Range request handling (video players request byte ranges for seeking)
  • Serving files from R2 with proper headers

Component 3: Jellyfin with STRM Files

Jellyfin supports STRM files: text files containing a URL. Instead of reading a local .mkv file, Jellyfin reads the STRM file and redirects the client to the URL inside it.

# /media/movies/Dune Part Two (2024)/Dune Part Two (2024).strm
https://media.yourdomain.com/movies/dune-part-two-2024.mkv?token=xyz

The client fetches the video directly from R2 via the Worker, bypassing your home server entirely for the actual media data.


Step 1: Upload Media to R2

Create an R2 bucket

  1. Cloudflare Dashboard > R2 > Create Bucket
  2. Name: jellyfin-media
  3. Location: Auto (Cloudflare distributes globally)

Upload with Rclone

# Configure Rclone for R2
rclone config
# Type: s3
# Provider: Cloudflare
# Access Key ID: from R2 API tokens
# Secret Access Key: from R2 API tokens
# Endpoint: https://<account-id>.r2.cloudflarestorage.com

# Sync your media library to R2
rclone sync /mnt/media/movies r2:jellyfin-media/movies --progress
rclone sync /mnt/media/tv r2:jellyfin-media/tv --progress

Initial upload depends on your upload speed. A 5 TB library at 100 Mbps upload takes ~4.5 days.

Ongoing sync

Schedule incremental sync after new content arrives:

# Cron: sync new content daily at 2 AM
0 2 * * * rclone sync /mnt/media r2:jellyfin-media --progress --log-file /var/log/r2-sync.log

Step 2: Create the Cloudflare Worker

JellyWatchTry JellyWatch — Your Jellyfin companion, everywhere.

The Worker authenticates requests and serves files from R2:

// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname.slice(1); // Remove leading /

    // Simple token authentication
    const token = url.searchParams.get("token");
    if (!token || token !== env.AUTH_TOKEN) {
      return new Response("Unauthorized", { status: 401 });
    }

    // Fetch from R2
    const object = await env.MEDIA_BUCKET.get(path);
    if (!object) {
      return new Response("Not Found", { status: 404 });
    }

    // Handle range requests (essential for video seeking)
    const range = request.headers.get("Range");
    if (range) {
      const [start, end] = range.replace("bytes=", "").split("-").map(Number);
      const objectSize = object.size;
      const actualEnd = end || objectSize - 1;
      const chunk = await env.MEDIA_BUCKET.get(path, {
        range: { offset: start, length: actualEnd - start + 1 }
      });

      return new Response(chunk.body, {
        status: 206,
        headers: {
          "Content-Range": `bytes ${start}-${actualEnd}/${objectSize}`,
          "Content-Length": actualEnd - start + 1,
          "Content-Type": object.httpMetadata?.contentType || "video/x-matroska",
          "Accept-Ranges": "bytes",
        },
      });
    }

    // Full file response
    return new Response(object.body, {
      headers: {
        "Content-Type": object.httpMetadata?.contentType || "video/x-matroska",
        "Content-Length": object.size,
        "Accept-Ranges": "bytes",
      },
    });
  },
};

Deploy with Wrangler:

npx wrangler deploy

Bind the R2 bucket to the Worker in wrangler.toml:

[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "jellyfin-media"

Step 3: Generate STRM Files

Create a script that generates STRM files for your Jellyfin library:

#!/bin/bash
# generate-strm.sh
BASE_URL="https://media.yourdomain.com"
TOKEN="your_secret_token"
MEDIA_DIR="/mnt/media/movies"
STRM_DIR="/mnt/jellyfin-strm/movies"

find "$MEDIA_DIR" -name "*.mkv" -o -name "*.mp4" | while read file; do
  relative="${file#$MEDIA_DIR/}"
  strm_path="$STRM_DIR/${relative%.*}.strm"
  mkdir -p "$(dirname "$strm_path")"
  echo "${BASE_URL}/${relative}?token=${TOKEN}" > "$strm_path"
done

Point Jellyfin at the STRM directory instead of (or alongside) your local media.


Step 4: Configure Jellyfin

  1. Add a new library pointing to the STRM directory
  2. Jellyfin reads the STRM files and fetches metadata normally
  3. When a user plays content, the client is redirected to the R2 URL
  4. The video streams from the nearest Cloudflare edge

Cost Analysis

R2 CDN approach

ComponentCost (5 TB library)
R2 storage$75/month
Workers (free tier: 100K requests/day)$0 (or $5/month for paid)
Egress bandwidth$0 (R2 has zero egress)
Total$75-80/month

Traditional VPS approach

ComponentCost
Hetzner VPS (CX32)$8/month
Hetzner Storage Box (5 TB)$12.50/month
Total$20.50/month

Home server approach

ComponentCost
Electricity$2/month
Domain$1/month
Total$3/month

When This Architecture Makes Sense

ScenarioR2 CDN worth it?
Users on one continent, good home uploadNo (home server is fine)
Users on multiple continentsYes (eliminates geographic latency)
Very large user base (50+)Yes (unlimited bandwidth)
Home upload under 20 MbpsMaybe (R2 offloads bandwidth)
Cost-sensitiveNo ($75/month vs $3/month home)

Limitations

  • No transcoding - R2 serves files as-is. If a client needs transcoding, it must go through your Jellyfin server
  • Upload delay - new content takes time to sync to R2
  • Cost - $75/month for 5 TB is significantly more than a home server
  • Complexity - Workers, STRM files, and sync scripts add operational overhead
  • STRM limitations - some Jellyfin clients handle STRM files differently; test your clients
  • No Direct Play detection - Jellyfin cannot determine if the client can Direct Play a STRM URL the same way it can for local files

FAQ

Does this violate Cloudflare ToS? R2 is designed for serving large files. Unlike the CDN proxy (which has Section 2.8 concerns for video), R2 is explicitly a storage product with no content-type restrictions.

Can I use this for transcoded streams? No. Transcoding requires real-time processing on a server. R2 only serves static files. Use this for Direct Play content only.

What about Backblaze B2 + Cloudflare? B2 has a bandwidth alliance with Cloudflare (free egress via Cloudflare CDN). Similar architecture is possible with B2 at $0.005/GB/month storage (cheaper than R2).

Does JellyWatch work with this setup? Yes. JellyWatch monitors Jellyfin sessions regardless of where the media files are stored. STRM-based playback appears as a normal session.


Global media delivery configured? Monitor every session from your phone. Download JellyWatch on Google Play - real-time sessions, bitrate monitoring, and server health.

On Emby? Download EmbyWatch on Google Play

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment

Never displayed publicly.
0 / 2000 · Supports limited Markdown: **bold**, *italic*, `code`, [link](url), lists, > quote.