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.
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 exampleimages/banner.jpgoraudio/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 examplef001,f002...). ThefNNNnumber 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:
| Field | Value |
|---|---|
| Type | CNAME |
| Name | media (becomes media.example.com) |
| Target | fNNN.backblazeb2.com (the host from Step 0) |
| Proxy status | Proxied (ORANGE cloud) - required |
| TTL | Auto |
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)
- Path -> Target path:
- 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
- Cache eligibility:
- 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_*, NextNEXT_PUBLIC_*), set the base URL variable (for exampleMEDIA_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.comURL gets you billed for B2 egress (no free tier benefit) and exposes the endpoint.
Troubleshooting
| Symptom | Fix |
|---|---|
| 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 MISS | Check the Cache Rule (Step 5). B2 may send a short Cache-Control; an Edge TTL set to "Override origin" overrides it. |
| 401 / 403 from B2 | The bucket is not Public - set it back to Public in B2. |
| DNS does not resolve | The 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.comon the login cookie. A host-only cookie is sent toapp.example.comonly and is NOT attached to requests formedia.example.com. That is what makes CORSAccess-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 enableAccess-Control-Allow-Credentials- but public media should not use credentials at all. - Always go through the proxied subdomain, and never let a
fNNN.backblazeb2.comURL 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.