Créer un CDN d’images avec Cloudflare R2 et Workers
Conservez votre image source dans un bucket R2 privé et publiez deux variantes redimensionnées
via un Worker : une WebP de 320 pixels et un JPEG de 640 pixels. Ce guide génère une image de test
reconnaissable, la déploie sur workers.dev et vérifie les pixels décodés ainsi
qu’un résultat effectivement servi depuis le cache. R2, Workers et Images ont des coûts distincts ;
une allocation de stockage gratuite ne rend pas ce CDN d’images gratuit sans condition.
Pourquoi choisir Cloudflare R2 pour votre CDN d’images ?
R2 sépare le stockage d’objets de leur diffusion. Le Worker lit les objets approuvés via une liaison et renvoie les octets transformés sans exposer d’URL R2 publique. La liaison Images accepte directement ces octets, ce qui permet au bucket source de rester privé.
Il s’agit d’un point de terminaison de publication pour les images que vous possédez et approuvez. Tout le monde peut accéder aux URL qu’il publie. Il n’autorise pas les téléchargements privés et ne traite pas les téléversements arbitraires.
Configurer votre CDN d’images
Préparer votre compte et vos outils
Utilisez un compte Cloudflare sur lequel R2 et Images sont déjà disponibles, un sous-domaine
workers.dev existant et un profil Wrangler authentifié nommé
devtips. Copiez l’identifiant de ce compte depuis le tableau de bord Cloudflare
et utilisez-le tout au long du guide. Vous n’avez besoin ni d’un domaine personnalisé ni d’une
modification DNS. Consultez les
profils d’authentification Wrangler
si vous devez d’abord préparer un profil.
Les commandes utilisent Bash sur macOS ou Linux, Node.js 26.8 et Corepack déjà installé. Corepack est un prérequis distinct ; consultez ses instructions d’installation s’il manque. Nous fixons les versions de Yarn à 4.12.0, de Wrangler à 4.141.0 et de Sharp à 0.35.3 dans le projet. La configuration système requise pour Wrangler s’applique également.
Enregistrez ce script sous setup.mts dans un répertoire extérieur à tout projet
existant utilisant un gestionnaire de paquets. Avant toute installation, il refuse les configurations
de gestionnaire de paquets dans le répertoire courant et les répertoires parents et tout répertoire
image-cdn-demo existant.
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
})
Exécutez-le depuis le répertoire contenant setup.mts :
node setup.mts
Si l’installation échoue après la création, conservez les fichiers et relancez l’installation :
(cd image-cdn-demo && corepack yarn install --no-immutable)
Ne relancez pas la création du projet dans un répertoire existant. Les commandes suivantes s’exécutent dans des sous-shells afin de laisser votre répertoire courant inchangé.
Configurer un nouveau Worker et un bucket privé
Choisissez un nom de ressource inutilisé en minuscules. Remplacez image-cdn-demo-8f6b2a
par ce nom dans la configuration et les commandes de cette page, et remplacez
YOUR_ACCOUNT_ID par l’identifiant de votre compte. Enregistrez cette configuration
complète pour un nouveau projet sous image-cdn-demo/wrangler.json ; elle ne remplace aucune
configuration existante.
{
"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
peut servir des réponses sans exécuter le Worker, y compris sur workers.dev.
Cet exemple utilise ce cache avec des en-têtes de réponse explicites, plutôt que
caches.default.
Créez le bucket distant dans le compte fixé par votre configuration :
(cd image-cdn-demo &&
corepack yarn wrangler r2 bucket create image-cdn-demo-8f6b2a --profile devtips)
Lorsque Wrangler vous demande s’il doit ajouter la liaison pour vous, répondez
n. La configuration ci-dessus définit déjà
IMAGES_BUCKET.
Les nouveaux buckets R2 sont privés par défaut. Laissez désactivés l’URL de développement publique et les domaines publics du bucket, comme indiqué dans la documentation sur les buckets R2 publics. Si la création signale que le nom existe, choisissez-en un autre ; ne téléversez rien dans un bucket que vous ne connaissez pas.
Générer une source reconnaissable
Enregistrez ce script sous image-cdn-demo/fixture.mts. Il crée un PNG RGB opaque de
800 × 600 avec quatre zones de couleur distinctes. Vous pourrez ainsi distinguer une transformation
correcte d’une image valide provenant d’une mauvaise source. Il refuse de remplacer un fichier
source existant.
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)
Pour vos propres images, vérifiez l’autorisation de publication et les métadonnées avant le téléversement. Les sources doivent être des PNG RGB opaques à une seule image, encodés sur 8 bits, d’une largeur comprise entre 640 et 4 096 pixels, avec au plus 12 millions de pixels décodés et une taille ne dépassant pas 8 MiB. Le Worker vérifie les octets et les dimensions ; l’opacité, l’encodage des couleurs, les métadonnées et l’autorisation de publication relèvent de votre processus de publication. N’accordez aucun accès en écriture à ce bucket à des acteurs non fiables et n’écrasez jamais un objet versionné publié. L’outil de vérification ci-dessous contrôle l’image de test à quatre couleurs générée ; utilisez les dimensions et le contenu attendus de votre propre image lorsque vous en vérifiez une autre.
Sélectionner uniquement les variantes publiées
Enregistrez ce Worker complet sous image-cdn-demo/worker.ts. Sa liste d’autorisation associe
/photo-v1.png à public/photo-v1.png. Les seules paires acceptées sont
w=320&f=webp et w=640&f=jpeg ; l’omission des deux paramètres
sélectionne la première paire. Les paramètres inconnus, les doublons et les autres paires
provoquent des erreurs, plutôt que de nouvelles transformations facturables.
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.')
}
},
}
La réponse en cas de succès autorise les lectures entre origines sans informations d’accès,
car ces images sont publiques. L’exemple ne nécessite ni jeton au porteur, ni espace de noms KV,
ni option d’authentification facultative.
X-Image-Invocation change chaque fois que le Worker calcule une nouvelle réponse image ;
une réponse mise en cache le conserve. Utilisez-le avec le statut du cache de Cloudflare pour
vérifier la réutilisation.
Compiler, téléverser et déployer
Générez les types des liaisons pour Env, puis vérifiez que Wrangler peut
produire le bundle du Worker enregistré :
(cd image-cdn-demo &&
corepack yarn wrangler types &&
corepack yarn wrangler deploy --dry-run --outdir build)
Téléversez le fichier généré dans le bucket distant et déployez uniquement une fois le téléversement réussi :
(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 affiche l’URL https://…workers.dev déployée. Conservez cette origine exacte pour
l’étape suivante. Si le déploiement échoue après le téléversement, corrigez la cause signalée et
relancez uniquement la commande de déploiement. Si le résultat d’un téléversement est incertain,
inspectez l’objet qui vous appartient avant de réessayer ; n’écrasez pas une source publiée pour
relancer la configuration.
Le stockage R2 local est distinct : r2 object put --local alimente uniquement le stockage
local de Wrangler, et wrangler dev utilise des liaisons locales. Cela ne téléverse
ni ne déploie cet exemple. Consultez les
commandes de gestion des objets R2
pour les options explicites --local et --remote.
Suivez la procédure ci-dessous sur la version déployée pour vérifier la transformation effectuée
par le fournisseur et le comportement du cache.
Téléverser et utiliser les images
Enregistrez le script suivant sous image-cdn-demo/verify.mts. Il télécharge les deux variantes
et répète la requête WebP. Remplacez YOUR_WORKER_ORIGIN dans la commande située en dessous
par l’origine exacte du déploiement. L’outil de vérification exige une réponse servie depuis le
cache avec le même identifiant d’invocation et les mêmes octets, et décode indépendamment chaque
fichier avec Sharp. Un statut HTTP 200 et un nom de fichier plausible ne suffisent pas à établir
le résultat.
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)
En cas de succès, vous obtenez une WebP de 320 × 240 et un JPEG de 640 × 480 dans
image-cdn-demo/downloads, avec les mêmes zones rouges, vertes, bleues et jaunes que la source.
Un troisième fichier contient la WebP récupérée lors de la requête répétée. Le script refuse un
répertoire de sortie existant. Après l’échec d’une requête, conservez ce répertoire et réessayez
avec un nouveau nom de répertoire :
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN downloads-retry-1)
Cette nouvelle tentative écrit uniquement dans downloads-retry-1 ; elle ne remplace pas
les téléchargements précédents.
Vérifier les requêtes canoniques et la mise en cache
L’URL par défaut /photo-v1.png et celle dont les paramètres de requête sont dans
l’ordre inverse, ?f=webp&w=320, renvoient une redirection 307 non mise en cache vers
?w=320&f=webp. Suivez cette redirection pour utiliser la même variante mise en cache.
La canonicalisation est nécessaire, car
les clés de Workers Cache incluent l’ordre des paramètres de requête.
Les résultats mis en cache peuvent expirer ou être évincés. Cette vérification démontre la
réutilisation depuis un seul emplacement client ; elle n’établit ni un taux global de réponses
servies depuis le cache ni une amélioration de la latence. Si l’outil de vérification signale
l’absence d’un résultat en cache, inspectez CF-Cache-Status à l’aide du
guide de débogage de Workers Cache.
Deux réponses réussies avec des identifiants d’invocation différents signifient que le Worker a
effectué le calcul deux fois.
Pour une nouvelle version de la source, utilisez un nouveau nom de fichier public et une nouvelle
clé pour l’objet sous-jacent, comme photo-v2.png et
public/photo-v2.png, puis mettez à jour la liste d’autorisation et déployez. N’écrasez pas
photo-v1.png. La version publique, la largeur et le format distinguent les corps
de réponse mis en cache. Un déploiement utilise par défaut une nouvelle version du cache du Worker,
mais ne peut pas supprimer une ancienne réponse déjà mise en cache par un navigateur.
Comprendre les échecs et les limites de la publication
Les paramètres invalides renvoient 400, les objets non publiés ou absents renvoient 404, les méthodes
non prises en charge renvoient 405 et les sources non prises en charge renvoient 422. Les échecs de
liaison ou de transformation renvoient une réponse 502 expurgée des informations sensibles.
Chaque échec est no-store ; une source absente n’est jamais remplacée par un
original non transformé. Les réponses réussies en cache qui n’ont pas expiré restent disponibles
jusqu’à leur expiration, même si l’objet sous-jacent est supprimé. stale-if-error=0
empêche une réponse réussie expirée de masquer une erreur ultérieure du Worker.
Supprimer une entrée de la liste d’autorisation et redéployer change la version du cache du Worker. Cela ne retire pas les copies déjà téléchargées ou mises en cache dans les navigateurs. Publiez uniquement le contenu que vous souhaitez rendre public.
Considérations sur les coûts
Consultez les tarifs actuels de R2, de Workers et d’Images avant le déploiement. Le stockage, les lectures d’objets, les requêtes Worker et les transformations uniques d’images ont des règles de facturation distinctes. La liste fixe de variantes limite les choix pour une source ; les nouvelles versions de la source ajoutent des transformations. L’absence d’un résultat en cache peut entraîner une nouvelle lecture et un nouveau décodage de la source, même si cette transformation a déjà été facturée.
Supprimer les ressources de démonstration
Supprimez uniquement le Worker et le bucket que vous avez créés pour cet exemple :
(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)
Confirmez que le Worker et le bucket sont absents de votre compte. Conservez votre source locale et vos téléchargements jusqu’à la fin de la vérification des résultats. Si le nettoyage s’arrête en cours de route, reprenez avec les commandes de suppression restantes pour ces mêmes noms de ressources qui vous appartiennent.
Vous disposez désormais d’un parcours de publication concret : un bucket source privé, deux variantes publiques choisies délibérément et une vérification qui distingue une réponse servie depuis le cache d’une nouvelle transformation. Pour une solution de diffusion d’images gérée, consultez le Smart CDN de Transloadit.
