Creación de una CDN de imágenes con Cloudflare R2 y Workers
Mantén tu imagen de origen en un bucket privado de R2 y publica dos variantes redimensionadas
mediante un Worker: WebP de 320 píxeles y JPEG de 640 píxeles. Este tutorial genera una imagen de
prueba reconocible, la despliega en workers.dev y comprueba tanto los píxeles
decodificados como un acierto real de caché. R2, Workers e Images tienen costos separados;
una cuota gratuita de almacenamiento no convierte esta CDN de imágenes en una opción gratuita
sin condiciones.
¿Por qué elegir Cloudflare R2 para tu CDN de imágenes?
R2 separa el almacenamiento de objetos de la entrega. El Worker lee los objetos aprobados mediante un binding y devuelve los bytes transformados sin exponer una URL pública de R2. El binding de Images acepta esos bytes directamente, por lo que el bucket de origen puede permanecer privado.
Este es un endpoint de publicación para imágenes que te pertenecen y que apruebas. Cualquier persona puede acceder a las URL publicadas. No autoriza descargas privadas ni procesa subidas arbitrarias.
Configuración de tu CDN de imágenes
Prepara tu cuenta y tus herramientas
Usa una cuenta de Cloudflare con R2 e Images ya disponibles, un subdominio
workers.dev existente y un perfil autenticado de Wrangler llamado
devtips. Copia el ID de esa cuenta desde el panel de Cloudflare y úsalo
durante todo el proceso. No necesitas un dominio personalizado ni un cambio de DNS.
Consulta los perfiles de autenticación de Wrangler
si primero necesitas preparar un perfil.
Los comandos usan Bash en macOS o Linux, Node.js 26.8 y Corepack ya instalado. Corepack es un requisito previo independiente; consulta sus instrucciones de instalación si no lo tienes. En el proyecto fijamos las versiones Yarn 4.12.0, Wrangler 4.141.0 y Sharp 0.35.3. También se aplican los requisitos del sistema de Wrangler.
Guarda lo siguiente como setup.mts en un directorio que esté fuera de un
proyecto de paquete existente. Antes de instalar nada, rechaza cualquier configuración de un
gestor de paquetes en los directorios contenedores y la existencia de un directorio
image-cdn-demo.
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
})
Ejecútalo desde el directorio que contiene setup.mts:
node setup.mts
Si la instalación falla después de la creación, conserva los archivos y vuelve a instalar:
(cd image-cdn-demo && corepack yarn install --no-immutable)
No vuelvas a ejecutar la creación del proyecto sobre un directorio existente. Los comandos restantes se ejecutan en subshells, por lo que no cambian tu directorio actual.
Configura un nuevo Worker y un bucket privado
Elige un nombre de recurso en minúsculas que no esté en uso. Sustituye
image-cdn-demo-8f6b2a en la configuración y los comandos de esta página por ese nombre,
y sustituye YOUR_ACCOUNT_ID por el ID de tu cuenta. Guarda esta configuración
completa para el proyecto nuevo como image-cdn-demo/wrangler.json; no reemplaza ninguna
configuración existente.
{
"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
puede servir respuestas sin ejecutar el Worker, incluso en workers.dev.
Este ejemplo usa esa caché con encabezados de respuesta explícitos, en lugar de
caches.default.
Crea el bucket remoto en la cuenta fijada por tu configuración:
(cd image-cdn-demo &&
corepack yarn wrangler r2 bucket create image-cdn-demo-8f6b2a --profile devtips)
Cuando Wrangler pregunte si debe añadir el binding por ti, responde
n. La configuración anterior ya define
IMAGES_BUCKET.
Los nuevos buckets de R2 son privados de forma predeterminada. Deja deshabilitados la URL pública de desarrollo y los dominios públicos del bucket, como se describe en buckets públicos de R2. Si la creación informa que el nombre ya existe, elige otro; no subas archivos a un bucket que no reconozcas.
Genera una imagen de origen que puedas reconocer
Guarda lo siguiente como image-cdn-demo/fixture.mts. Crea un PNG RGB opaco de 800 × 600 con
cuatro regiones de colores distintos, para que puedas distinguir una transformación correcta
de una imagen válida que provenga de un origen equivocado. No permite reemplazar un archivo de
origen existente.
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)
Para tus propias imágenes, revisa el permiso de publicación y los metadatos antes de subirlas. Usa imágenes de origen PNG RGB opacas de 8 bits y un solo fotograma, con un ancho de entre 640 y 4.096 píxeles, un máximo de 12 millones de píxeles decodificados y un tamaño no mayor de 8 MiB. El Worker comprueba los bytes y las dimensiones; la opacidad, la codificación de color, los metadatos y el permiso de publicación corresponden a tu proceso de publicación. No permitas el acceso de escritura a este bucket a actores no confiables y nunca sobrescribas un objeto versionado ya publicado. El verificador que aparece más adelante comprueba la imagen de prueba generada de cuatro colores; usa tus propias dimensiones y contenido esperados al comprobar otra imagen.
Selecciona solo las variantes publicadas
Guarda este Worker completo como image-cdn-demo/worker.ts. Su lista de permitidos asocia
/photo-v1.png con public/photo-v1.png. Los únicos pares aceptados son
w=320&f=webp y w=640&f=jpeg;
al omitir ambos parámetros se selecciona el primer par. Los parámetros desconocidos, los
duplicados y los demás pares producen errores, en lugar de nuevas transformaciones 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 respuesta exitosa permite lecturas entre orígenes sin credenciales porque estas imágenes son
públicas. El ejemplo no necesita un token Bearer, un espacio de nombres KV ni una opción de
autenticación adicional.
X-Image-Invocation cambia cada vez que el Worker calcula una nueva respuesta de
imagen; una respuesta almacenada en caché lo conserva. Úsalo junto con el estado de caché de
Cloudflare al comprobar la reutilización.
Compila, sube y despliega
Genera los tipos de los bindings para Env y luego comprueba que
Wrangler pueda empaquetar el Worker guardado:
(cd image-cdn-demo &&
corepack yarn wrangler types &&
corepack yarn wrangler deploy --dry-run --outdir build)
Sube el archivo generado al bucket remoto y despliega solo después de que la subida se complete correctamente:
(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 muestra la URL desplegada de https://…workers.dev. Conserva ese origen exacto
para el siguiente paso. Si el despliegue falla después de la subida, resuelve la causa indicada
y vuelve a ejecutar solo el comando de despliegue. Si el resultado de una subida es incierto,
inspecciona el objeto de tu propiedad antes de volver a intentarlo; no sobrescribas una imagen
de origen publicada para reintentar la configuración.
R2 local es independiente: r2 object put --local solo carga el almacenamiento local de
Wrangler, y wrangler dev usa bindings locales. No sube ni despliega este ejemplo.
Consulta los comandos de objetos de R2
para conocer las opciones explícitas --local y
--remote. Usa la ruta desplegada que aparece a continuación para verificar
la transformación del proveedor y el comportamiento de la caché.
Subida y uso de imágenes
Guarda lo siguiente como image-cdn-demo/verify.mts. Descarga ambas variantes y repite la
solicitud de WebP. Sustituye YOUR_WORKER_ORIGIN en el comando que aparece debajo por
el origen exacto desplegado. El verificador exige un acierto de caché con el mismo ID de
invocación y los mismos bytes, y decodifica cada archivo de forma independiente con Sharp.
Por sí solos, HTTP 200 y un nombre de archivo verosímil no bastan para confirmar el resultado.
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)
Si se completa correctamente, deja un WebP de 320 × 240 y un JPEG de 640 × 480 en
image-cdn-demo/downloads, con las mismas regiones rojas, verdes, azules y amarillas que la
imagen de origen. Un tercer archivo contiene el WebP repetido. El script rechaza un directorio
de salida existente. Después de una solicitud fallida, conserva ese directorio y vuelve a
intentarlo con un nombre de directorio nuevo:
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN downloads-retry-1)
El nuevo intento escribe solo en downloads-retry-1; no reemplaza las descargas
anteriores.
Comprueba las solicitudes canónicas y el almacenamiento en caché
La URL predeterminada /photo-v1.png y el orden de consulta invertido
?f=webp&w=320 devuelven una redirección 307 sin almacenamiento en caché a
?w=320&f=webp. Sigue esa redirección para usar la misma variante almacenada en
caché. La canonicalización es necesaria porque
las claves de Workers Cache incluyen el orden de los parámetros de consulta.
Las entradas de caché que producen aciertos pueden caducar o ser desalojadas. Esta comprobación
demuestra la reutilización desde una ubicación de cliente; no establece una tasa global de
aciertos ni una mejora de latencia. Si el verificador informa de un fallo de caché, inspecciona
CF-Cache-Status con la
guía de depuración de Workers Cache.
Dos respuestas exitosas con distintos IDs de invocación significan que el Worker calculó dos veces.
Usa un nombre de archivo público y una clave de objeto de respaldo nuevos para una nueva versión
de la imagen de origen, como photo-v2.png y public/photo-v2.png,
y luego actualiza la lista de permitidos y despliega. No sobrescribas
photo-v1.png. La versión pública, el ancho y el formato distinguen los cuerpos
de respuesta almacenados en caché. De forma predeterminada, un despliegue usa una nueva versión
de caché del Worker, pero no puede borrar una respuesta anterior ya almacenada en la caché de
un navegador.
Comprende los fallos y el límite de publicación
Los parámetros no válidos devuelven 400, los objetos no publicados o ausentes devuelven 404,
los métodos no admitidos devuelven 405 y las imágenes de origen no admitidas devuelven 422.
Los fallos del binding o de la transformación devuelven un 502 sin información sensible.
Cada fallo es no-store; si falta una imagen de origen, nunca se recurre a
un original sin transformar. Las respuestas exitosas almacenadas en caché y aún vigentes siguen
disponibles hasta su caducidad, incluso si se elimina el objeto que las respalda.
stale-if-error=0 impide que una respuesta exitosa caducada oculte un error posterior
del Worker.
Eliminar una entrada de la lista de permitidos y volver a desplegar cambia la versión de caché del Worker. No retira las copias ya descargadas o almacenadas en la caché de los navegadores. Publica solo contenido que quieras hacer público.
Consideraciones de costos
Consulta los precios de R2, los precios de Workers y los precios de Images antes del despliegue. El almacenamiento, las lecturas de objetos, las solicitudes al Worker y las transformaciones únicas de imágenes tienen reglas de facturación independientes. La lista fija de variantes limita las opciones para una imagen de origen; las nuevas versiones de esa imagen añaden transformaciones. Un fallo de caché puede provocar que se vuelva a leer y decodificar la imagen de origen, incluso si esa transformación ya se facturó.
Elimina los recursos de la demostración
Elimina solo el Worker y el bucket que creaste para este ejemplo:
(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)
Confirma que el Worker y el bucket ya no estén en tu cuenta. Conserva tu imagen de origen local y las descargas hasta que termines de comprobar los resultados. Si la limpieza se detiene a mitad del proceso, continúa con los comandos de eliminación restantes para esos mismos nombres de recursos de tu propiedad.
Ahora tienes una ruta de publicación concreta: un bucket privado de origen, dos variantes públicas elegidas deliberadamente y una comprobación que distingue un acierto de caché de otra transformación. Como alternativa de entrega de imágenes gestionada, consulta la Smart CDN de Transloadit.
