Construir una CDN de imágenes con Cloudflare R2 y Workers
R2 puede almacenar tus imágenes de origen, un Worker puede seleccionar una variante y el binding de Cloudflare Images puede redimensionarla y codificarla. Son servicios independientes con límites de uso independientes. Un proyecto pequeño puede caber dentro de sus cuotas gratuitas, pero una CDN de imágenes no es gratuita de forma incondicional.
Este ejemplo publica un conjunto pequeño de imágenes aprobadas explícitamente. No es una puerta de enlace para archivos privados ni un procesador de subidas arbitrarias.
¿Por qué Cloudflare R2 para tu CDN de imágenes?
R2 separa el almacenamiento de objetos de la entrega. Mantén el bucket privado y deja que el Worker lea los objetos aprobados a través de un binding. El Worker devuelve los bytes transformados en lugar de exponer una URL de R2.
El redimensionamiento requiere un servicio de transformación de imágenes: colocar cf.image en un Response no
transforma su cuerpo. Usamos el
binding de Images real.
Configurar tu CDN de imágenes
Paso 1: Crear una cuenta de Cloudflare
Activa R2, Workers e Images en tu cuenta y revisa su configuración de facturación actual. Instala
Node.js y crea un proyecto de Worker con la
guía de inicio de Cloudflare.
Usa un Worker de módulos ES; la implementación siguiente es src/index.js.
Paso 2: Crear un bucket de R2
Crea un bucket llamado image-cdn-demo en el panel de R2. Deja deshabilitados tanto la URL pública
de desarrollo como los dominios personalizados del bucket público. Sube solo imágenes que tengas
permiso para publicar.
Nuestra lista de permitidos asigna el nombre de archivo público photo.jpg a public/photo-v1.jpg. Antes de
subirlo, comprueba que sea un JPEG o PNG de un solo fotograma, que no supere 8 MiB ni 12 millones de
píxeles decodificados. Elimina los metadatos privados durante tu proceso de publicación. Nunca
sobrescribas objetos versionados.
Paso 3: Crear un worker
El Worker completo admite tres anchos y dos formatos. Rechazar los parámetros desconocidos o
duplicados mantiene finito el conjunto de transformaciones. El formato es explícito en la URL, por
lo que la caché no depende de la cabecera Accept del navegador.
const published = new Map([['photo.jpg', 'public/photo-v1.jpg']])
const widths = new Set(['320', '640', '1280'])
const formats = new Map([['webp', 'image/webp'], ['jpeg', 'image/jpeg']])
const MAX_BYTES = 8 * 1024 * 1024
function failure(status, message, extra = {}) {
return new Response(message, {
status,
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8', ...extra },
})
}
export default {
async fetch(request, env, ctx) {
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.')
const pairs = [...url.searchParams]
if (pairs.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') ?? '640'
const format = url.searchParams.get('f') ?? 'webp'
if (!widths.has(width) || !formats.has(format)) {
return failure(400, 'Unsupported image variant.')
}
// Normalize defaults/order and include the immutable source version in the internal cache key.
const cacheUrl = new URL('/_image-cache/' + key, url.origin)
cacheUrl.searchParams.set('w', width)
cacheUrl.searchParams.set('f', format)
const cacheKey = new Request(cacheUrl, { method: 'GET' })
const cache = caches.default
try {
let response = await cache.match(cacheKey)
if (!response) {
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 output = await env.IMAGES.input(object.body)
.transform({ width: Number(width) })
.output({ format: formats.get(format) })
const transformed = output.response()
response = new Response(transformed.body, transformed)
response.headers.set('Cache-Control', 'public, max-age=3600')
response.headers.set('X-Content-Type-Options', 'nosniff')
// Public images only: this endpoint does not authorize private content.
response.headers.set('Access-Control-Allow-Origin', '*')
ctx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => {
console.error('Image cache write failed.')
}))
}
return request.method === 'HEAD'
? new Response(null, response)
: response
} catch {
console.error('Image delivery failed.')
return failure(502, 'Image is temporarily unavailable.')
}
},
}
Paso 4: Configurar los bindings del worker
Agrega estos bindings a la configuración de Wrangler generada, conservando los demás campos del proyecto:
{
"name": "image-cdn-demo",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"r2_buckets": [
{ "binding": "IMAGES_BUCKET", "bucket_name": "image-cdn-demo" }
],
"images": { "binding": "IMAGES" }
}
Esta implementación usa explícitamente la Cache API de Workers. No agregues una segunda caché automática de respuestas sin comprobar su clave de caché, su invalidación y su comportamiento de autorización.
Paso 5: Configurar tu dominio
Empieza de forma local con npx wrangler dev. El R2 local es independiente del bucket desplegado;
cárgalo con un objeto local antes de hacer pruebas. La emulación local de Images solo admite un
subconjunto de las opciones de producción, así que verifica tus variantes desplegadas antes de
dirigir tráfico hacia ellas.
Despliega con npx wrangler deploy. En la configuración del Worker, agrega un
dominio personalizado de Workers
para un dominio de tu cuenta de Cloudflare. Un registro DNS por sí solo no vincula el Worker.
Subir y usar imágenes
Carga el bucket de desarrollo local con tu archivo de origen revisado:
npx wrangler r2 object put image-cdn-demo/public/photo-v1.jpg --file ./photo.jpg --local
curl --fail-with-body 'http://localhost:8787/photo.jpg?w=320&f=webp' --output photo-320.webp
curl --fail-with-body --head 'http://localhost:8787/photo.jpg?f=jpeg&w=640'
Para el despliegue, sube el mismo archivo aprobado a través del panel de R2 o usa de forma
deliberada la opción --remote de Wrangler. Las subidas locales no se copian automáticamente a
producción.
Consideraciones de costos
Consulta los precios de R2, los precios de Workers y los precios de Images actuales antes de desplegar. El almacenamiento, las lecturas de objetos, las solicitudes al Worker y las transformaciones de imágenes tienen cuotas y reglas de facturación diferentes. Superar una cuota gratuita puede generar cargos o hacer que se rechacen solicitudes, según el servicio y el plan. No deduzcas que existe una cuota gratuita de transformaciones a partir de la política de salida de datos de R2.
Presupuesta los fallos de caché y las nuevas versiones de origen. Un acierto de caché en el edge no garantiza que todas las solicitudes futuras eviten el trabajo de almacenamiento o de transformación.
Mejores prácticas de seguridad
La lista de permitidos es un límite de publicación, no un mecanismo de autenticación. Cualquiera que conozca una URL aprobada puede obtener su imagen. No la asignes a subidas personales ni a archivos con controles de acceso.
Para una entrega privada, diseña la autorización y el almacenamiento en caché privado en conjunto antes de adaptar este código. Guarda las credenciales en bindings o secretos del Worker, nunca en cadenas de consulta. Aplica controles de abuso y alertas de uso a nivel de cuenta. Un contador de lectura/incremento/escritura en KV no es un limitador de tasa atómico.
Manejo de solicitudes CORS
La respuesta de imagen correcta permite cualquier origen porque estas imágenes son públicas. Esto
habilita el uso de fetch() y de canvas en el navegador sin credenciales. La visualización
ordinaria de img entre orígenes no requiere CORS por sí misma. El endpoint no admite
solicitudes con credenciales ni cabeceras de solicitud personalizadas que requieran una solicitud
preflight.
Manejo de errores
Los parámetros inválidos devuelven 400, los nombres no publicados devuelven 404 y los métodos no
admitidos devuelven 405. Los archivos de origen ausentes o inutilizables no recurren a la imagen
original como alternativa. Una transformación fallida devuelve una respuesta 502 saneada con
no-store, no detalles del proveedor ni páginas de error en caché.
Transformaciones de imagen compatibles
Usa w=320, w=640 o w=1280, con f=webp o f=jpeg. Omitir los parámetros
selecciona WebP de 640 píxeles. El redimensionamiento solo por ancho conserva la relación de
aspecto; el origen aprobado determina la altura. JPEG no conserva la transparencia. Publica orígenes
opacos cuando ambas variantes deban verse idénticas. Los formatos adicionales o las políticas de
recorte requieren cambios explícitos en la lista de permitidos y en las pruebas.
Limitaciones de las imágenes
El Worker comprueba el tamaño en bytes del objeto. El número de píxeles, el formato de un solo fotograma y el permiso de publicación son requisitos del momento de publicación, y no se aplican confiando en un nombre de archivo o en una cabecera content-type. No permitas que personas no confiables puedan escribir en este bucket. Los objetos grandes o no compatibles deben rechazarse antes de la publicación; el proveedor también aplica sus propios límites de decodificación.
Comportamiento de la caché
Una respuesta correcta es pública durante una hora. La clave interna incluye la versión del objeto de origen, el ancho y el formato. El orden de la consulta y los valores predeterminados omitidos se resuelven en la misma clave interna, mientras que JPEG y WebP nunca comparten un cuerpo en caché.
La Cache API es local en cada centro de datos de Cloudflare; no es un almacén de objetos replicado globalmente. Usa un nombre de archivo público nuevo para las actualizaciones que deban omitir de inmediato las cachés del navegador. Cambiar solo el objeto que respalda la lista de permitidos no puede invalidar una respuesta del navegador que ya está en caché.
Configurar variables de entorno
Este ejemplo no necesita ningún token bearer, espacio de nombres de KV ni indicador de autenticación opcional. Los bindings proporcionan el acceso de lectura privado a R2 y la conexión con el servicio de imágenes. Mantén separados los buckets de desarrollo y de producción, y no uses bindings remotos por accidente en las pruebas automatizadas.
Conclusión
R2, Workers e Images aportan las piezas de un servicio de entrega de imágenes. Mantén explícitas las decisiones de publicación, la selección de variantes y el almacenamiento en caché, verifica los contratos de servicio actuales y supervisa los tres presupuestos de uso. Para una alternativa gestionada, consulta el Smart CDN de Transloadit.
