Vũ Văn HảiFull-stack · AI-native
GuidesBlog
Discuss a project

© 2026 Vu Van Hai · Written from real deployment experience.

HomeGuidesBlogRSS
  1. Guides
  2. /VPS
  3. /Serve media via Cloudflare CDN + Backblaze B2 (free egress)

Serve media via Cloudflare CDN + Backblaze B2 (free egress)

Put Cloudflare in front of a public Backblaze B2 bucket as a CDN to serve images, audio and video from a dedicated media subdomain: DNS, URL rewrite, CORS and cache rules, with free B2 egress.

Updated: Sep 21, 20269 min read
CloudflareCDNBackblaze B2Performance
On this page
  • Benefits and request flow
  • Quick reference
  • Step by step
  • Step 0: Prerequisites and finding your B2 download host
  • Step 1: DNS - point the media subdomain at B2
  • Step 2: SSL/TLS = Full
  • Step 3: URL Rewrite Rule - prepend /file/<bucket>
  • Step 4: CORS - only if the app fetch()es files from JavaScript
  • Step 5: Cache Rule - cache media for a long time
  • Step 6: Verify
  • Step 7: Wire it into the app
  • Troubleshooting
  • Security and important notes
  • Cost

This guide serves large static files (images, audio, video, subtitles, fonts, JSON...) from Backblaze B2 with Cloudflare as the CDN, reachable at https://media.example.com/<path>. Use it when your app is media-heavy and you want fast global delivery with almost no bandwidth bill: you only pay for B2 storage.

Throughout, replace media.example.com (the media subdomain), app.example.com (your main app domain), <bucket> (your B2 bucket name) and fNNN.backblazeb2.com (your B2 download host - see Step 0) with your real values.

Benefits and request flow

  • Cloudflare's global cache: faster loads and less traffic hitting B2.
  • Free B2 egress when traffic goes through Cloudflare (Bandwidth Alliance) - you pay for storage only, not for outbound bandwidth.
  • Automatic HTTPS, and the real B2 endpoint stays hidden from end users.

Request flow:

Browser
  -> https://media.example.com/<path>              (through Cloudflare, proxied)
  -> [URL Rewrite Rule] path becomes -> /file/<bucket>/<path>
  -> Backblaze B2 (public bucket)                  (free egress + CF cache)

Quick reference

Everything happens in the domain's Cloudflare dashboard (except the verify step, which uses a terminal).

0. Prerequisites: B2 bucket = Public with files uploaded; domain already on Cloudflare.
   Find your B2 download host: B2 console -> Browse Files -> click a file -> see "Friendly URL"
   -> take the fNNN.backblazeb2.com part (e.g. f001, f002...).
   Native B2 URL: https://fNNN.backblazeb2.com/file/<bucket>/<key>

1. DNS  (Cloudflare -> DNS -> Add record)
   Type=CNAME   Name=media   Target=fNNN.backblazeb2.com   Proxy status=Proxied (ORANGE cloud)

2. SSL/TLS -> Overview -> Encryption mode = Full

3. URL Rewrite Rule  (Rules -> Overview -> URL Rewrite Rules -> Create rule)
   Rule name:   media-b2-path-rewrite
   If match:    Wildcard pattern  ->  Request URL = https://media.example.com/*
   Then:        Path -> Target path = /*        Path -> Rewrite to = /file/<bucket>/${1}
                Query -> leave BOTH fields EMPTY
   Place at:    Last        -> Deploy

4. CORS  (Rules -> Overview -> Response Header Transform Rules -> Create rule)
   [ONLY NEEDED if the app loads files via fetch()/XHR/<track> in JS: subtitles, JSON, fonts]
   Rule name:   media-cors
   If:          Hostname  equals  media.example.com
   Then:        Set static -> Header name = Access-Control-Allow-Origin , Value = *
                (stricter: Value = https://app.example.com)
                -> Deploy

5. Cache Rule  (Caching -> Cache Rules -> Create rule)
   Rule name:   media-cache
   If:          Hostname  equals  media.example.com
   Then:        Cache eligibility = Eligible | Edge TTL = Override origin 1 month
                | Browser TTL = Override origin 1 day
                -> Deploy

6. Verify  (in Windows PowerShell use curl.exe, NOT curl)
   curl.exe -I https://media.example.com/<path>                                # use a file that REALLY exists; 200 + correct content-type; run it TWICE -> cf-cache-status: HIT
   curl.exe -I -H "Range: bytes=0-1023" https://media.example.com/<video.mp4>  # 206 Partial Content (video seeking)
   curl.exe -I https://media.example.com/<subtitle.vtt>                        # must include the access-control-allow-origin header (if you did Step 4)

Step by step

Step 0: Prerequisites and finding your B2 download host

  • The B2 bucket is set to Public and already has files. A key (the file's path inside the bucket) looks like <path>, for example images/banner.jpg or audio/lesson-01.mp3.
  • The domain is already on Cloudflare (nameservers point to Cloudflare).
  • Find your B2 download host: open the B2 console -> Browse Files -> click any file -> look at Friendly URL. It starts with fNNN.backblazeb2.com (for example f001, f002...). The fNNN number differs per B2 region/account, so use yours.
  • A native B2 URL always looks like https://fNNN.backblazeb2.com/file/<bucket>/<key>.

Why not let the app call the fNNN... B2 URL directly? Direct calls are billed as B2 egress (they skip Cloudflare, so you lose the Bandwidth Alliance benefit) and expose the real B2 endpoint. A dedicated subdomain routed through Cloudflare gives you free egress plus caching and HTTPS.

Step 1: DNS - point the media subdomain at B2

Cloudflare dashboard -> pick the domain -> DNS -> Add record:

FieldValue
TypeCNAME
Namemedia (becomes media.example.com)
TargetfNNN.backblazeb2.com (the host from Step 0)
Proxy statusProxied (ORANGE cloud) - required
TTLAuto

It must be Proxied (orange cloud). With "DNS only" (grey) traffic bypasses Cloudflare: no cache, no free egress, and none of the Rules below will run.

Step 2: SSL/TLS = Full

Cloudflare -> SSL/TLS -> Overview -> Encryption mode = Full.

Cloudflare talks to B2 over HTTPS, and B2 has a valid certificate for *.backblazeb2.com. "Full (strict)" works too; if you hit a 525/526 error, drop back to Full.

Step 3: URL Rewrite Rule - prepend /file/<bucket>

B2 serves files at /file/<bucket>/<key>, but the app should call the short form media.example.com/<key> (without /file/<bucket>). This rule inserts that prefix as Cloudflare forwards the request to B2.

Go to Rules -> Overview -> the URL Rewrite Rules block -> Create rule. Fill in:

  • Rule name: media-b2-path-rewrite
  • If incoming requests match... -> choose Wildcard pattern
  • Request URL: https://media.example.com/*
  • Then rewrite the path and/or query...
    • Path -> Target path: /*
    • Path -> Rewrite to: /file/<bucket>/${1}
    • Query -> Target query: leave EMPTY
    • Query -> Rewrite to: leave EMPTY (keeps the query string as is)
  • Place at: Last
  • Click Deploy.

How it works: ${1} is whatever the * in the Target path /* captured (the whole path after the first /).

Result: media.example.com/images/banner.jpg -> the origin receives /file/<bucket>/images/banner.jpg -> B2 returns the file.

Watch for a stray /: the Target path / Rewrite to fields usually show a grey leading / (a fixed Cloudflare prefix). The final values must be /* and /file/<bucket>/${1} - NOT //* or //file/... (an extra slash makes B2 return 404). After you Deploy, confirm with the curl commands in Step 6.

Step 4: CORS - only if the app fetch()es files from JavaScript

You need CORS only when the browser loads files through JS/XHR/fetch() or a <track> tag (for example .txt/.vtt subtitle files, JSON, dynamically loaded fonts). Those are cross-origin requests (from app.example.com to media.example.com), and the browser blocks them without a CORS header.

Images/audio/video loaded through <img> / <audio> / <video> tags do NOT need CORS. If your app only uses those tags, you can skip this step.

Go to Rules -> Overview -> the Response Header Transform Rules block -> Create rule:

  • Rule name: media-cors
  • If...: Field Hostname - equals - media.example.com
  • Then...: Set static -> Header name Access-Control-Allow-Origin, Value *
  • Deploy.

Using * is safe when the media is public and requests carry no credentials (see the Security section below). For a tighter setup, set Value = https://app.example.com (your app domain only).

Step 5: Cache Rule - cache media for a long time

Media is practically immutable (its content does not change), so cache it for a long time to speed things up and save requests to B2.

Go to Caching (left menu) -> Cache Rules -> Create rule:

  • Rule name: media-cache
  • If...: Field Hostname - equals - media.example.com
  • Then...:
    • Cache eligibility: Eligible for cache
    • Edge TTL: Override origin -> 1 month
    • Browser TTL: Override origin -> 1 day
  • Deploy.

If you later replace a file but keep the SAME name (key), the Cloudflare and browser caches may keep serving the old copy until the TTL expires. To avoid that, rename the file when its content changes (for example add a version/hash to the name), or purge the cache manually in Cloudflare.

Step 6: Verify

Run these in a terminal. Windows PowerShell must use curl.exe, not curl; on macOS/Linux plain curl is fine.

1. Regular file (use a file that REALLY exists): expect 200 and the correct content-type. Run it a SECOND time and you should see the cf-cache-status: HIT header.

curl.exe -I https://media.example.com/<path>

2. Video Range (seeking): expect 206 Partial Content.

curl.exe -I -H "Range: bytes=0-1023" https://media.example.com/<video.mp4>

3. CORS (if you did Step 4): the response must include the access-control-allow-origin header.

curl.exe -I https://media.example.com/<subtitle.vtt>

All three passing means you are done.

Step 7: Wire it into the app

  • In code, build media URLs as https://media.example.com/<path>.
  • If your framework inlines variables at build time (for example Vite import.meta.env.VITE_*, Next NEXT_PUBLIC_*), set the base URL variable (for example MEDIA_BASE_URL=https://media.example.com) at build time instead of hardcoding it.
  • Always go through the proxied subdomain. Pointing straight at the fNNN.backblazeb2.com URL gets you billed for B2 egress (no free tier benefit) and exposes the endpoint.

Troubleshooting

SymptomFix
404 "file not found"The URL Rewrite Rule is wrong (Step 3). Test B2 directly: curl.exe -I https://fNNN.backblazeb2.com/file/<bucket>/<key> - if that returns 200 but media.example.com returns 404, the path rewrite rule is the problem.
404 even though the rule "looks right"The Target path / Rewrite to field has an extra leading / (it became //file/...). Fix it to exactly /* and /file/<bucket>/${1}.
Console says "blocked by CORS"The CORS rule (Step 4) is missing, or the hostname in its condition is wrong. Only affects files loaded via JS (subtitles/JSON/fonts).
525 / 526 (SSL)Switch the SSL/TLS mode to Full (Step 2).
cf-cache-status is always MISSCheck the Cache Rule (Step 5). B2 may send a short Cache-Control; an Edge TTL set to "Override origin" overrides it.
401 / 403 from B2The bucket is not Public - set it back to Public in B2.
DNS does not resolveThe media record is not Proxied, was not saved, or has not propagated yet.

Security and important notes

  • A Public bucket means anyone with the URL can download. Keep only public files (your app's media) in this bucket; NEVER store sensitive/private files there.
  • Keep the app's session cookie host-only: do not set Domain=.example.com on the login cookie. A host-only cookie is sent to app.example.com only and is NOT attached to requests for media.example.com. That is what makes CORS Access-Control-Allow-Origin: * safe (media requests carry no credentials).
  • Never return * for CORS if credentials must be sent. Browsers reject * + credentials; you would have to set a specific origin (https://app.example.com) and enable Access-Control-Allow-Credentials - but public media should not use credentials at all.
  • Always go through the proxied subdomain, and never let a fNNN.backblazeb2.com URL leak into public code/HTML (you lose free egress and expose the endpoint).

Cost

Cloudflare <-> B2 traffic is part of the Bandwidth Alliance, so B2 egress is free when it goes through Cloudflare (proxied). Users download from the Cloudflare cache. The Cloudflare Free plan is enough for typical needs. You only pay for B2 storage (cheap) and almost nothing for bandwidth.

PreviousQuick-check a VPS's specs with a single commandNextWhere to put apps on a VPS: the /opt/apps directory layout

Related articles

  • Tune VPS performance: swap, PostgreSQL and the DB pool

    Speed up a 16GB Ubuntu VPS that runs many Docker containers next to PostgreSQL: add swap to keep the OOM Killer away, tune PostgreSQL for more RAM and SSD storage, then raise the app's connection pool.

    Database

    Database
  • Automatic maintenance page with Caddy during Docker deploys

    Use Caddy's handle_errors to catch the 502 Bad Gateway that appears while a container rebuilds and serve a static maintenance page automatically, with no manual on/off switch.

    VPS

    VPS
  • Set up Caddy as a reverse proxy on a VPS with Docker

    Run Caddy as a Dockerized reverse proxy on a fresh VPS: create a shared network, write the Caddyfile and docker-compose file, get HTTPS automatically, then add new domains with a single zero-downtime reload.

    VPS

    VPS

Written by Vu Van Hai

I'm Hai, a full-stack developer based in Ho Chi Minh City. These guides come from systems I built and run myself. Need to build or untangle something similar? Get in touch.

Discuss a projectMore guides

Spot a mistake or a command that no longer works? Let me know

On this page

  • Benefits and request flow
  • Quick reference
  • Step by step
  • Step 0: Prerequisites and finding your B2 download host
  • Step 1: DNS - point the media subdomain at B2
  • Step 2: SSL/TLS = Full
  • Step 3: URL Rewrite Rule - prepend /file/<bucket>
  • Step 4: CORS - only if the app fetch()es files from JavaScript
  • Step 5: Cache Rule - cache media for a long time
  • Step 6: Verify
  • Step 7: Wire it into the app
  • Troubleshooting
  • Security and important notes
  • Cost