Ein Bild-CDN mit Cloudflare R2 und Workers aufbauen
Bewahren Sie Ihr Quellbild in einem privaten R2-Bucket auf und veröffentlichen Sie über einen Worker
zwei Varianten mit angepasster Größe: WebP mit 320 Pixeln und JPEG mit 640 Pixeln. Diese Anleitung
erzeugt ein erkennbares Testbild, stellt es auf workers.dev bereit und prüft sowohl
die decodierten Pixel als auch einen tatsächlichen Cache-Treffer. Für R2, Workers und Images fallen
separate Kosten an. Ein kostenloses Speicherkontingent macht daraus kein uneingeschränkt kostenloses
Bild-CDN.
Warum Cloudflare R2 für Ihr Bild-CDN?
R2 trennt Objektspeicher und Auslieferung. Der Worker liest freigegebene Objekte über ein Binding und gibt transformierte Bytes zurück, ohne eine öffentliche R2-URL offenzulegen. Das Images-Binding nimmt diese Bytes direkt entgegen, sodass der Quell-Bucket privat bleiben kann.
Dies ist ein Endpunkt zur Veröffentlichung von Bildern, die Ihnen gehören und die Sie freigeben. Jeder kann die veröffentlichten URLs abrufen. Der Endpunkt autorisiert keine privaten Downloads und verarbeitet keine beliebigen Uploads.
Ihr Bild-CDN einrichten
Konto und Werkzeuge vorbereiten
Verwenden Sie ein Cloudflare-Konto, in dem R2 und Images bereits verfügbar sind, eine bestehende
Subdomain unter workers.dev und ein authentifiziertes Wrangler-Profil namens
devtips. Kopieren Sie die ID dieses Kontos aus dem Cloudflare-Dashboard und
verwenden Sie sie durchgehend. Sie benötigen weder eine eigene Domain noch eine DNS-Änderung.
Unter Wrangler-Authentifizierungsprofile
erfahren Sie, wie Sie bei Bedarf zunächst ein Profil vorbereiten.
Die Befehle verwenden Bash unter macOS oder Linux, Node.js 26.8 und ein bereits installiertes Corepack. Corepack ist eine separate Voraussetzung. Falls es fehlt, lesen Sie die Installationsanleitung. Im Projekt legen wir Yarn 4.12.0, Wrangler 4.141.0 und Sharp 0.35.3 als feste Versionen fest. Die Systemanforderungen von Wrangler gelten ebenfalls.
Speichern Sie dies als setup.mts in einem Verzeichnis außerhalb eines bestehenden
Paketprojekts. Das Skript bricht vor jeder Installation ab, wenn es eine übergeordnete
Paketmanager-Konfiguration oder ein bereits vorhandenes Verzeichnis
image-cdn-demo findet.
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
async function main(): Promise<void> {
let parent = resolve('.')
for (;;) {
for (const file of ['package.json', 'yarn.lock', '.yarnrc.yml', '.pnp.cjs']) {
if (existsSync(join(parent, file))) {
throw new Error('Choose a directory outside an existing package project.')
}
}
if (dirname(parent) === parent) break
parent = dirname(parent)
}
const probe = spawnSync('corepack', ['--version'], { stdio: 'inherit' })
if (probe.status !== 0) throw new Error('Install corepack before creating the project.')
await mkdir('image-cdn-demo')
await writeFile('image-cdn-demo/package.json', JSON.stringify({
name: 'image-cdn-demo', private: true, type: 'module', packageManager: 'yarn@4.12.0',
devDependencies: { wrangler: '4.141.0', sharp: '0.35.3' },
}, null, 2) + '\n', { flag: 'wx' })
await writeFile('image-cdn-demo/.yarnrc.yml', 'nodeLinker: node-modules\n', { flag: 'wx' })
const install = spawnSync('corepack', ['yarn', 'install', '--no-immutable'], {
cwd: 'image-cdn-demo', stdio: 'inherit',
})
if (install.status !== 0) throw new Error('Installation failed; keep the project and retry inside it.')
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Setup failed.')
process.exitCode = 1
})
Führen Sie es aus dem Verzeichnis aus, das setup.mts enthält:
node setup.mts
Falls die Installation nach der Erstellung fehlschlägt, behalten Sie die Dateien und wiederholen Sie die Installation:
(cd image-cdn-demo && corepack yarn install --no-immutable)
Führen Sie die Projekterstellung nicht erneut in einem bestehenden Verzeichnis aus. Die übrigen Befehle laufen in Subshells und lassen daher Ihr aktuelles Verzeichnis unverändert.
Neuen Worker und privaten Bucket konfigurieren
Wählen Sie einen noch unbenutzten Ressourcennamen in Kleinbuchstaben. Ersetzen Sie
image-cdn-demo-8f6b2a in der Konfiguration und den Befehlen dieser Seite durch diesen Namen
und YOUR_ACCOUNT_ID durch Ihre Konto-ID. Speichern Sie diese vollständige Konfiguration
für ein neues Projekt als image-cdn-demo/wrangler.json. Sie ersetzt keine bestehende Konfiguration.
{
"name": "image-cdn-demo-8f6b2a",
"account_id": "YOUR_ACCOUNT_ID",
"main": "worker.ts",
"compatibility_date": "2026-10-04",
"workers_dev": true,
"preview_urls": false,
"cache": { "enabled": true },
"r2_buckets": [
{ "binding": "IMAGES_BUCKET", "bucket_name": "image-cdn-demo-8f6b2a" }
],
"images": { "binding": "IMAGES" }
}
Workers Cache
kann Antworten ausliefern, ohne den Worker auszuführen, auch auf workers.dev.
Dieses Beispiel nutzt diesen Cache mit expliziten Antwort-Headern statt
caches.default.
Erstellen Sie den entfernten Bucket in dem Konto, das Ihre Konfiguration festlegt:
(cd image-cdn-demo &&
corepack yarn wrangler r2 bucket create image-cdn-demo-8f6b2a --profile devtips)
Wenn Wrangler fragt, ob es das Binding für Sie hinzufügen soll, antworten Sie mit
n. Die Konfiguration oben definiert bereits
IMAGES_BUCKET.
Neue R2-Buckets sind standardmäßig privat. Lassen Sie die öffentliche Entwicklungs-URL und öffentliche Bucket-Domains deaktiviert, wie unter Öffentliche R2-Buckets beschrieben. Falls bei der Erstellung gemeldet wird, dass der Name bereits existiert, wählen Sie einen anderen. Laden Sie nichts in einen Ihnen unbekannten Bucket hoch.
Ein erkennbares Quellbild erzeugen
Speichern Sie dies als image-cdn-demo/fixture.mts. Es erzeugt ein nicht transparentes RGB-PNG
mit 800 × 600 und vier klar unterscheidbaren Farbbereichen. So können Sie eine korrekte
Transformation von einem gültigen Bild aus der falschen Quelle unterscheiden. Das Skript ersetzt
keine bestehende Quelldatei.
import { writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const colors = [[210, 40, 40], [40, 160, 60], [30, 70, 210], [230, 200, 40]]
const pixels = Buffer.alloc(800 * 600 * 3)
for (let y = 0; y < 600; y++) {
for (let x = 0; x < 800; x++) {
const color = colors[(y < 300 ? 0 : 2) + (x < 400 ? 0 : 1)]
pixels.set(color, (y * 800 + x) * 3)
}
}
const png = await sharp(pixels, { raw: { width: 800, height: 600, channels: 3 } }).png().toBuffer()
await writeFile('photo-v1.png', png, { flag: 'wx' })
(cd image-cdn-demo && node fixture.mts)
Prüfen Sie bei eigenen Bildern vor dem Hochladen die Veröffentlichungsrechte und Metadaten. Verwenden Sie als Quellen nicht transparente 8-Bit-RGB-PNGs mit einem einzelnen Frame, einer Breite zwischen 640 und 4.096 Pixeln, höchstens 12 Millionen decodierten Pixeln und maximal 8 MiB Dateigröße. Der Worker prüft Bytes und Abmessungen. Deckkraft, Farbcodierung, Metadaten und Veröffentlichungsrechte gehören in Ihren Veröffentlichungsprozess. Verweigern Sie nicht vertrauenswürdigen Akteuren Schreibzugriff auf diesen Bucket und überschreiben Sie niemals ein veröffentlichtes versioniertes Objekt. Das Prüfskript unten prüft die erzeugte vierfarbige Fixture. Verwenden Sie beim Prüfen eines anderen Bildes Ihre eigenen erwarteten Abmessungen und Inhalte.
Nur die veröffentlichten Varianten auswählen
Speichern Sie diesen vollständigen Worker als image-cdn-demo/worker.ts. Seine Positivliste
ordnet /photo-v1.png dem Wert public/photo-v1.png zu. Die einzigen
zulässigen Paare sind w=320&f=webp und w=640&f=jpeg.
Werden beide Parameter weggelassen, wird das erste Paar ausgewählt. Unbekannte Parameter,
Duplikate und andere Paare führen zu Fehlern statt zu neuen kostenpflichtigen Transformationen.
const published = new Map([['photo-v1.png', 'public/photo-v1.png']])
const variants = new Map<string, { width: number; format: 'image/webp' | 'image/jpeg' }>([
['320:webp', { width: 320, format: 'image/webp' }],
['640:jpeg', { width: 640, format: 'image/jpeg' }],
])
const MAX_BYTES = 8 * 1024 * 1024
function failure(status: number, message: string, extra: Record<string, string> = {}): Response {
return new Response(message, {
status,
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8', ...extra },
})
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return failure(405, 'Use GET or HEAD.', { Allow: 'GET, HEAD' })
}
const url = new URL(request.url)
const key = published.get(url.pathname.slice(1))
if (!key) return failure(404, 'Image not found.')
if ([...url.searchParams].some(([name]) => name !== 'w' && name !== 'f') ||
url.searchParams.getAll('w').length > 1 || url.searchParams.getAll('f').length > 1) {
return failure(400, 'Unsupported image parameters.')
}
const width = url.searchParams.get('w') ?? '320'
const format = url.searchParams.get('f') ?? 'webp'
const variant = variants.get(`${width}:${format}`)
if (!variant) return failure(400, 'Unsupported image variant.')
// Workers Cache distinguishes query order; redirect aliases before doing image work.
const canonical = new URL(url.pathname, url.origin)
canonical.search = `?w=${width}&f=${format}`
if (url.href !== canonical.href) {
return failure(307, 'Use the canonical image URL.', { Location: canonical.href })
}
try {
const object = await env.IMAGES_BUCKET.get(key)
if (!object) return failure(404, 'Image not found.')
if (object.size === 0 || object.size > MAX_BYTES) {
await object.body.cancel()
return failure(422, 'Image is outside the supported limits.')
}
const bytes = await object.arrayBuffer()
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (!signature.every((value, index) => new Uint8Array(bytes)[index] === value)) {
return failure(422, 'Publish a supported PNG source.')
}
const info = await env.IMAGES.info(new Blob([bytes]).stream()).catch(() => null)
if (!info || !('width' in info) || !('height' in info) ||
info.width < 640 || info.width > 4096 || info.width * info.height > 12_000_000) {
return failure(422, 'Image is outside the supported limits.')
}
const output = await env.IMAGES.input(new Blob([bytes]).stream())
.transform({ width: variant.width })
.output({ format: variant.format })
const response = output.response({ headers: {
'Cache-Control': 'public, max-age=3600, stale-if-error=0',
'Access-Control-Allow-Origin': '*',
'X-Content-Type-Options': 'nosniff',
'X-Image-Invocation': crypto.randomUUID(),
} })
return request.method === 'HEAD' ? new Response(null, response) : response
} catch {
console.error('Image delivery failed.')
return failure(502, 'Image is temporarily unavailable.')
}
},
}
Die erfolgreiche Antwort erlaubt ursprungsübergreifende Lesezugriffe ohne Zugangsdaten, da diese
Bilder öffentlich sind. Das Beispiel benötigt weder ein Bearer-Token noch einen KV-Namespace oder
ein optionales Authentifizierungsflag. X-Image-Invocation ändert sich jedes Mal, wenn der
Worker eine neue Bildantwort berechnet. Eine gecachte Antwort behält den Wert bei. Verwenden Sie
ihn zusammen mit dem Cache-Status von Cloudflare, um die Wiederverwendung zu prüfen.
Erstellen, hochladen und bereitstellen
Erzeugen Sie die Binding-Typen für Env und prüfen Sie anschließend,
ob Wrangler den gespeicherten Worker bündeln kann:
(cd image-cdn-demo &&
corepack yarn wrangler types &&
corepack yarn wrangler deploy --dry-run --outdir build)
Laden Sie die erzeugte Datei in den entfernten Bucket hoch und stellen Sie den Worker erst bereit, nachdem der Upload erfolgreich war:
(cd image-cdn-demo &&
corepack yarn wrangler r2 object put image-cdn-demo-8f6b2a/public/photo-v1.png \
--file photo-v1.png --content-type image/png --remote --profile devtips &&
corepack yarn wrangler deploy --profile devtips)
Wrangler gibt die bereitgestellte URL unter https://…workers.dev aus. Bewahren Sie genau
diesen Ursprung für den nächsten Schritt auf. Falls die Bereitstellung nach dem Upload fehlschlägt,
beheben Sie die gemeldete Ursache und wiederholen Sie nur den Bereitstellungsbefehl. Falls das
Ergebnis eines Uploads unklar ist, prüfen Sie vor einem erneuten Versuch das Objekt in Ihrem Besitz.
Überschreiben Sie keine veröffentlichte Quelle, um die Einrichtung erneut zu versuchen.
Lokales R2 ist davon getrennt: r2 object put --local befüllt nur den lokalen Speicher von
Wrangler, und wrangler dev verwendet lokale Bindings. Damit wird dieses Beispiel
weder hochgeladen noch bereitgestellt. Unter
R2-Objektbefehle
finden Sie die expliziten Flags --local und --remote.
Verwenden Sie den unten beschriebenen Weg über die bereitgestellte Version, um die Transformation
beim Anbieter und das Cache-Verhalten zu überprüfen.
Bilder hochladen und verwenden
Speichern Sie Folgendes als image-cdn-demo/verify.mts. Das Skript lädt beide Varianten herunter
und wiederholt die WebP-Anfrage. Ersetzen Sie YOUR_WORKER_ORIGIN im Befehl darunter durch
den exakten Ursprung der bereitgestellten Version. Das Prüfskript verlangt einen Cache-Treffer mit
derselben Aufruf-ID und denselben Bytes und decodiert jede Datei unabhängig mit Sharp. HTTP 200 und
ein plausibler Dateiname allein reichen nicht aus, um das Ergebnis nachzuweisen.
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const origin = new URL(process.argv[2])
assert(origin.protocol === 'https:')
assert(origin.pathname === '/' && !origin.search && !origin.hash)
const directory = process.argv[3] ?? 'downloads'
await mkdir(directory)
const colors = [[210, 40, 40], [40, 160, 60], [30, 70, 210], [230, 200, 40]]
async function download(query: string, file: string, width: number, format: string) {
const response = await fetch(new URL(`/photo-v1.png?${query}`, origin))
assert.equal(response.status, 200)
assert.equal(response.headers.get('content-type'), `image/${format}`)
const bytes = Buffer.from(await response.arrayBuffer())
assert.equal((await sharp(bytes).metadata()).format, format)
const decoded = await sharp(bytes).removeAlpha().raw().toBuffer({ resolveWithObject: true })
assert.equal(decoded.info.width, width)
assert.equal(decoded.info.height, width * 3 / 4)
assert.equal(decoded.info.channels, 3)
for (const [index, [x, y]] of [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]].entries()) {
const offset = (Math.floor(y * decoded.info.height) * width + Math.floor(x * width)) * 3
for (let channel = 0; channel < 3; channel++) {
assert(Math.abs(decoded.data[offset + channel] - colors[index][channel]) <= 6)
}
}
await writeFile(`${directory}/${file}`, bytes, { flag: 'wx' })
return { bytes, invocation: response.headers.get('x-image-invocation'),
cache: response.headers.get('cf-cache-status') }
}
const cold = await download('w=320&f=webp', 'photo-320.webp', 320, 'webp')
const warm = await download('w=320&f=webp', 'photo-320-repeat.webp', 320, 'webp')
assert.equal(warm.cache, 'HIT', 'Repeat request did not demonstrate a cache hit.')
assert(cold.invocation)
assert.equal(warm.invocation, cold.invocation, 'Worker computed the image again.')
assert(cold.bytes.equals(warm.bytes))
await download('w=640&f=jpeg', 'photo-640.jpeg', 640, 'jpeg')
console.log('Verified both variants and a cache hit with the same invocation.')
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN)
Bei Erfolg liegen unter image-cdn-demo/downloads ein WebP mit 320 × 240 und ein JPEG
mit 640 × 480, mit denselben roten, grünen, blauen und gelben Bereichen wie im Quellbild.
Eine dritte Datei enthält das erneut abgerufene WebP. Das Skript lehnt ein bereits vorhandenes
Ausgabeverzeichnis ab. Behalten Sie dieses Verzeichnis nach einer fehlgeschlagenen Anfrage und
versuchen Sie es mit einem neuen Verzeichnisnamen erneut:
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN downloads-retry-1)
Der erneute Versuch schreibt nur nach downloads-retry-1 und ersetzt keine früheren
Downloads.
Kanonische Anfragen und Caching prüfen
Die Standard-URL /photo-v1.png und die umgekehrte Reihenfolge der Anfrageparameter
?f=webp&w=320 liefern eine nicht gecachte 307-Weiterleitung zu
?w=320&f=webp. Folgen Sie dieser Weiterleitung, um dieselbe gecachte Variante zu
nutzen. Die Kanonisierung ist nötig, da
Cache-Schlüssel von Workers Cache die Reihenfolge der Anfrageparameter berücksichtigen.
Cache-Einträge können ablaufen oder verdrängt werden. Diese Prüfung zeigt die Wiederverwendung
von einem einzelnen Client-Standort aus. Sie belegt weder eine globale Trefferquote noch eine
verbesserte Latenz. Falls das Prüfskript einen Cache-Miss meldet, untersuchen Sie
CF-Cache-Status mithilfe der
Debugging-Anleitung für Workers Cache.
Zwei erfolgreiche Antworten mit unterschiedlichen Aufruf-IDs bedeuten, dass der Worker zweimal
gerechnet hat.
Verwenden Sie für eine neue Quellversion einen neuen öffentlichen Dateinamen und einen neuen
zugehörigen Objektschlüssel, etwa photo-v2.png und public/photo-v2.png.
Aktualisieren Sie dann die Positivliste und stellen Sie den Worker bereit. Überschreiben Sie
photo-v1.png nicht. Öffentliche Version, Breite und Format unterscheiden die
gecachten Antwortkörper. Eine Bereitstellung verwendet standardmäßig eine neue Cache-Version des
Workers, kann aber keine alte Antwort löschen, die ein Browser bereits gecacht hat.
Fehler und Grenzen der Veröffentlichung verstehen
Ungültige Parameter liefern 400, unveröffentlichte oder fehlende Objekte 404, nicht unterstützte
Methoden 405 und nicht unterstützte Quellen 422. Bei Binding- oder Transformationsfehlern wird eine
bereinigte 502-Antwort zurückgegeben. Jeder Fehler ist no-store. Bei fehlenden
Quellen wird niemals auf ein untransformiertes Original zurückgegriffen. Noch gültige gecachte
Erfolgsantworten bleiben bis zum Ablauf verfügbar, selbst wenn das zugehörige Objekt entfernt wird.
stale-if-error=0 verhindert, dass eine abgelaufene Erfolgsantwort einen späteren
Worker-Fehler verdeckt.
Wenn Sie einen Eintrag aus der Positivliste entfernen und den Worker erneut bereitstellen, ändert sich seine Cache-Version. Bereits heruntergeladene oder in Browsern gecachte Kopien werden dadurch nicht zurückgerufen. Veröffentlichen Sie nur Inhalte, die Sie öffentlich zugänglich machen möchten.
Kosten berücksichtigen
Prüfen Sie vor der Bereitstellung die aktuellen Preise für R2, Preise für Workers und Preise für Images. Für Speicher, Objektlesezugriffe, Worker-Anfragen und eindeutige Bildtransformationen gelten separate Abrechnungsregeln. Die feste Variantenliste begrenzt die Auswahl für eine Quelle. Neue Quellversionen fügen Transformationen hinzu. Bei einem Cache-Miss kann die Quelle erneut gelesen und decodiert werden, selbst wenn diese Transformation bereits abgerechnet wurde.
Demo-Ressourcen entfernen
Löschen Sie nur den Worker und den Bucket, die Sie für dieses Beispiel erstellt haben:
(cd image-cdn-demo &&
corepack yarn wrangler delete --name image-cdn-demo-8f6b2a --force --profile devtips &&
corepack yarn wrangler r2 object delete image-cdn-demo-8f6b2a/public/photo-v1.png --remote --profile devtips &&
corepack yarn wrangler r2 bucket delete image-cdn-demo-8f6b2a --profile devtips)
Vergewissern Sie sich, dass Worker und Bucket nicht mehr in Ihrem Konto vorhanden sind. Behalten Sie Ihre lokale Quelle und die Downloads, bis Sie die Ergebnisse vollständig geprüft haben. Falls die Bereinigung vorzeitig stoppt, setzen Sie sie mit den verbleibenden Löschbefehlen für dieselben Namen Ihrer eigenen Ressourcen fort.
Sie haben nun einen konkreten Veröffentlichungsweg: einen privaten Quell-Bucket, zwei bewusst gewählte öffentliche Varianten und eine Prüfung, die einen Cache-Treffer von einer weiteren Transformation unterscheidet. Eine verwaltete Alternative zur Bildauslieferung bietet das Smart CDN von Transloadit.
