Criando uma CDN de imagens com Cloudflare R2 e Workers
O R2 pode armazenar suas imagens de origem, um Worker pode selecionar uma variante e o binding do Cloudflare Images pode redimensioná-la e codificá-la. São serviços separados, com limites de uso separados. Um projeto pequeno pode caber nas cotas gratuitas deles, mas uma CDN de imagens não é gratuita incondicionalmente.
Este exemplo publica um conjunto pequeno de imagens aprovadas explicitamente. Ele não é um gateway de arquivos privados nem um processador de uploads arbitrários.
Por que usar o Cloudflare R2 na sua CDN de imagens?
O R2 separa o armazenamento de objetos da entrega. Mantenha o bucket privado e deixe o Worker ler os objetos aprovados por meio de um binding. O Worker retorna os bytes transformados em vez de expor uma URL do R2.
O redimensionamento exige um serviço de transformação de imagens: colocar cf.image em um Response não
transforma o corpo dele. Usamos o verdadeiro
binding do Images.
Configurando sua CDN de imagens
Passo 1: crie uma conta na Cloudflare
Ative R2, Workers e Images na sua conta e revise as configurações de cobrança atuais de cada um.
Instale o Node.js e crie um projeto de Worker com o
guia de primeiros passos da Cloudflare.
Use um Worker em módulo ES; a implementação abaixo é src/index.js.
Passo 2: crie um bucket R2
Crie um bucket chamado image-cdn-demo no painel do R2. Deixe desativados tanto a URL pública de
desenvolvimento quanto os domínios personalizados de bucket público. Faça upload apenas de imagens
que você tem permissão para publicar.
Nossa lista de permissões mapeia o nome de arquivo público photo.jpg para public/photo-v1.jpg. Antes do
upload, verifique se é um JPEG ou PNG de quadro único, com no máximo 8 MiB e 12 milhões de pixels
decodificados. Remova metadados privados durante o seu processo de publicação. Nunca sobrescreva
objetos versionados.
Passo 3: crie um Worker
O Worker completo aceita três larguras e dois formatos. Rejeitar parâmetros desconhecidos ou
duplicados mantém finito o conjunto de transformações. O formato é explícito na URL, então o cache
não depende do cabeçalho Accept do 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.')
}
},
}
Passo 4: configure os bindings do Worker
Adicione estes bindings à configuração do Wrangler gerada, preservando os outros campos do projeto:
{
"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 implementação usa a Cache API dos Workers explicitamente. Não adicione um segundo cache automático de respostas sem verificar a chave de cache, a invalidação e o comportamento de autorização dele.
Passo 5: configure seu domínio
Comece localmente com npx wrangler dev. O R2 local é separado do bucket implantado; popule-o com um
objeto local antes de testar. A emulação local do Images aceita apenas um subconjunto das opções de
produção, então verifique as variantes implantadas antes de direcionar tráfego para elas.
Faça o deploy com npx wrangler deploy. Nas configurações do Worker, adicione um
domínio personalizado do Workers
para um domínio da sua conta Cloudflare. Um registro DNS sozinho não vincula o Worker.
Fazendo upload e usando imagens
Popule o bucket de desenvolvimento local com o arquivo de origem 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 o deploy, faça upload do mesmo arquivo aprovado pelo painel do R2 ou use deliberadamente a flag
--remote do Wrangler. Uploads locais não são copiados automaticamente para produção.
Considerações de custo
Confira os preços do R2, os preços do Workers e os preços do Images atuais antes do deploy. Armazenamento, leituras de objetos, requisições do Worker e transformações de imagens têm cotas e regras de cobrança diferentes. Ultrapassar uma cota gratuita pode gerar cobranças ou fazer com que requisições sejam rejeitadas, dependendo do serviço e do plano. Não deduza uma cota gratuita de transformações a partir da política de egress do R2.
Reserve orçamento para cache misses e novas versões de origem. Um cache hit na borda não garante que toda requisição futura evite trabalho de armazenamento ou de transformação.
Boas práticas de segurança
A lista de permissões é um limite de publicação, não um mecanismo de autenticação. Qualquer pessoa que conheça uma URL aprovada pode obter a imagem correspondente. Não a mapeie para uploads pessoais nem para arquivos com controles de acesso.
Para entrega privada, projete a autorização e o cache privado em conjunto antes de adaptar este código. Mantenha as credenciais em bindings ou secrets do Worker, nunca em query strings. Aplique controles contra abuso no nível da conta e alertas de uso. Um contador de leitura/incremento/gravação no KV não é um limitador de taxa atômico.
Lidando com requisições CORS
A resposta de imagem bem-sucedida permite qualquer origem porque essas imagens são públicas. Isso
permite o uso de fetch() no navegador e em canvas sem credenciais. A exibição comum de img entre
origens não exige CORS por si só. O endpoint não aceita requisições com credenciais nem cabeçalhos de
requisição personalizados que exijam preflight.
Tratamento de erros
Parâmetros inválidos retornam 400, nomes não publicados retornam 404 e métodos não suportados
retornam 405. Arquivos de origem ausentes ou inutilizáveis não recorrem à imagem original como
fallback. Uma transformação que falha retorna uma resposta 502 sanitizada com no-store, e não
detalhes do provedor nem páginas de erro em cache.
Transformações de imagem suportadas
Use w=320, w=640 ou w=1280, com f=webp ou f=jpeg. Omitir os parâmetros seleciona
WebP de 640 pixels. O redimensionamento apenas pela largura preserva a proporção; a origem aprovada
determina a altura. O JPEG não preserva transparência. Publique origens opacas quando as duas
variantes precisarem ter aparência idêntica. Formatos adicionais ou políticas de corte exigem
mudanças explícitas na lista de permissões e nos testes.
Limitações de imagem
O Worker verifica o tamanho do objeto em bytes. A contagem de pixels, o formato de quadro único e a permissão de publicação são requisitos do momento da publicação, e não são garantidos confiando em um nome de arquivo ou em um cabeçalho content-type. Não conceda permissão de escrita neste bucket a usuários não confiáveis que fazem upload. Objetos grandes ou não suportados precisam ser rejeitados antes da publicação; o provedor também aplica seus próprios limites de decodificação.
Comportamento do cache
Uma resposta bem-sucedida fica pública por uma hora. A chave interna inclui a versão do objeto de origem, a largura e o formato. A ordem da query e os padrões omitidos resultam na mesma chave interna, enquanto JPEG e WebP nunca compartilham um corpo em cache.
A Cache API é local a cada data center da Cloudflare; ela não é um armazenamento de objetos replicado globalmente. Use um novo nome de arquivo público para atualizações que precisem ignorar imediatamente os caches dos navegadores. Alterar apenas o objeto por trás de uma entrada da lista de permissões não invalida uma resposta já armazenada no cache do navegador.
Configurando variáveis de ambiente
Este exemplo não precisa de bearer token, namespace KV nem flag opcional de autenticação. Os bindings fornecem o acesso privado de leitura ao R2 e a conexão com o serviço de imagens. Mantenha separados os buckets de desenvolvimento e de produção, e não use bindings remotos por acidente em testes automatizados.
Conclusão
R2, Workers e Images fornecem as peças para um serviço de entrega de imagens. Mantenha explícitas as decisões de publicação, a seleção de variantes e o cache, verifique os contratos atuais dos serviços e monitore os três orçamentos de uso. Para uma alternativa gerenciada, veja o Smart CDN da Transloadit.
