Criar uma CDN de imagens com Cloudflare R2 e Workers
Mantenha sua imagem de origem em um bucket R2 privado e publique duas variantes redimensionadas por
meio de um Worker: WebP de 320 pixels e JPEG de 640 pixels. Este tutorial gera uma imagem de teste
reconhecível, implanta-a em workers.dev e verifica tanto os pixels decodificados
quanto um acerto real de cache. R2, Workers e Images têm custos separados; uma franquia gratuita de
armazenamento não torna esta CDN de imagens gratuita sem condições.
Por que usar o Cloudflare R2 para sua CDN de imagens?
O R2 separa o armazenamento de objetos da entrega. O Worker lê objetos aprovados por meio de um binding e retorna bytes transformados sem expor uma URL pública do R2. O binding do Images aceita esses bytes diretamente, então o bucket de origem pode permanecer privado.
Este é um endpoint de publicação para imagens que você possui e aprova. Qualquer pessoa pode acessar as URLs publicadas por ele. Ele não autoriza downloads privados nem processa uploads arbitrários.
Configurar sua CDN de imagens
Preparar sua conta e suas ferramentas
Use uma conta Cloudflare com R2 e Images já disponíveis, um subdomínio
workers.dev existente e um perfil autenticado do Wrangler chamado
devtips. Copie o ID dessa conta no painel da Cloudflare e use-o em todo o
procedimento. Você não precisa de um domínio personalizado nem de uma alteração de DNS.
Consulte Perfis de autenticação do Wrangler
se precisar preparar um perfil primeiro.
Os comandos usam Bash no macOS ou Linux, Node.js 26.8 e Corepack já instalado. O Corepack é um pré-requisito separado; consulte as instruções de instalação se ele não estiver instalado. Fixamos as versões Yarn 4.12.0, Wrangler 4.141.0 e Sharp 0.35.3 no projeto. Os requisitos de sistema do Wrangler também se aplicam.
Salve o conteúdo abaixo como setup.mts em um diretório fora de um projeto de
pacote existente. Antes de instalar qualquer coisa, ele recusa configurações de gerenciadores de
pacotes em diretórios superiores e a existência do diretório 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
})
Execute-o no diretório que contém setup.mts:
node setup.mts
Se a instalação falhar após a criação, preserve os arquivos e tente instalar novamente:
(cd image-cdn-demo && corepack yarn install --no-immutable)
Não execute novamente a criação do projeto sobre um diretório existente. Os demais comandos são executados em subshells, portanto não alteram seu diretório atual.
Configurar um novo Worker e um bucket privado
Escolha um nome de recurso em minúsculas que ainda não esteja em uso. Substitua
image-cdn-demo-8f6b2a na configuração e nos comandos desta página por esse nome e substitua
YOUR_ACCOUNT_ID pelo ID da sua conta. Salve esta configuração completa de um novo
projeto como image-cdn-demo/wrangler.json; ela não substitui nenhuma configuração 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" }
}
O Workers Cache
pode servir respostas sem executar o Worker, inclusive em workers.dev. Este
exemplo usa esse cache com cabeçalhos de resposta explícitos, em vez de
caches.default.
Crie o bucket remoto na conta fixada pela sua configuração:
(cd image-cdn-demo &&
corepack yarn wrangler r2 bucket create image-cdn-demo-8f6b2a --profile devtips)
Quando o Wrangler perguntar se deve adicionar o binding por você, responda
n. A configuração acima já define IMAGES_BUCKET.
Novos buckets R2 são privados por padrão. Mantenha desativados a URL pública de desenvolvimento e os domínios públicos do bucket, conforme descrito em Buckets públicos do R2. Se a criação informar que o nome já existe, escolha outro nome; não faça upload para um bucket que você não reconhece.
Gerar uma imagem de origem reconhecível
Salve o conteúdo abaixo como image-cdn-demo/fixture.mts. Ele cria um PNG RGB opaco de 800 × 600
com quatro regiões de cores distintas, para que você possa diferenciar uma transformação correta de
uma imagem válida gerada a partir da origem errada. Ele se recusa a substituir um arquivo de origem
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 suas próprias imagens, revise a permissão de publicação e os metadados antes de fazer upload. Use como origens apenas PNGs RGB opacos de 8 bits, com um único quadro, largura entre 640 e 4.096 pixels, no máximo 12 milhões de pixels decodificados e tamanho de até 8 MiB. O Worker verifica bytes e dimensões; opacidade, codificação de cores, metadados e permissão de publicação são responsabilidade do seu processo de publicação. Não permita acesso de escrita a agentes não confiáveis neste bucket e nunca sobrescreva um objeto versionado já publicado. O verificador abaixo valida a imagem de teste gerada com quatro cores; use as dimensões e o conteúdo esperados por você ao verificar outra imagem.
Selecionar apenas as variantes publicadas
Salve este Worker completo como image-cdn-demo/worker.ts. Sua lista de permissões mapeia
/photo-v1.png para public/photo-v1.png. Os únicos pares aceitos são
w=320&f=webp e w=640&f=jpeg; omitir ambos os parâmetros seleciona
o primeiro par. Parâmetros desconhecidos, duplicados e outros pares geram erros, em vez de novas
transformações faturáveis.
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.')
}
},
}
A resposta de sucesso permite leituras entre origens sem credenciais porque essas imagens são
públicas. O exemplo não precisa de token bearer, namespace KV nem sinalizador opcional de
autenticação. X-Image-Invocation muda sempre que o Worker calcula uma nova resposta de
imagem; uma resposta em cache o mantém. Use-o junto com o status de cache da Cloudflare ao verificar
o reúso.
Compilar, fazer upload e implantar
Gere os tipos dos bindings para Env e verifique se o Wrangler consegue
empacotar o Worker salvo:
(cd image-cdn-demo &&
corepack yarn wrangler types &&
corepack yarn wrangler deploy --dry-run --outdir build)
Faça upload do arquivo gerado para o bucket remoto e implante somente após o sucesso do upload:
(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)
O Wrangler exibe a URL https://…workers.dev da implantação. Guarde essa origem exata para
a próxima etapa. Se a implantação falhar após o upload, corrija a causa informada e tente novamente
apenas o comando de implantação. Se o resultado de um upload for incerto, inspecione o objeto que
pertence a você antes de tentar novamente; não sobrescreva uma origem publicada para refazer a
configuração.
O R2 local é separado: r2 object put --local preenche apenas o armazenamento local do
Wrangler, e wrangler dev usa bindings locais. Isso não faz upload nem implanta
este exemplo. Consulte os
comandos de objetos do R2
para saber mais sobre os sinalizadores explícitos --local e
--remote. Use o caminho implantado abaixo para verificar a transformação feita
pelo provedor e o comportamento do cache.
Fazer upload e usar imagens
Salve o conteúdo a seguir como image-cdn-demo/verify.mts. Ele baixa ambas as variantes e repete
a requisição WebP. Substitua YOUR_WORKER_ORIGIN no comando abaixo dele pela origem exata
da implantação. O verificador exige um acerto de cache com o mesmo ID de invocação e os mesmos
bytes, além de decodificar cada arquivo de forma independente com o Sharp. Apenas HTTP 200 e um
nome de arquivo plausível não são suficientes para comprovar o 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)
Em caso de sucesso, um WebP de 320 × 240 e um JPEG de 640 × 480 ficam em
image-cdn-demo/downloads, com as mesmas regiões vermelha, verde, azul e amarela da origem.
Um terceiro arquivo contém o WebP repetido. O script recusa um diretório de saída existente. Após
uma requisição malsucedida, preserve esse diretório e tente novamente com um novo nome de diretório:
(cd image-cdn-demo && node verify.mts https://YOUR_WORKER_ORIGIN downloads-retry-1)
A nova tentativa grava apenas em downloads-retry-1; ela não substitui downloads
anteriores.
Verificar requisições canônicas e cache
A URL padrão /photo-v1.png e a ordem invertida dos parâmetros de consulta
?f=webp&w=320 retornam um redirecionamento 307 não armazenado em cache para
?w=320&f=webp. Siga esse redirecionamento para usar a mesma variante em cache. A
canonicalização é necessária porque
as chaves do Workers Cache incluem a ordem dos parâmetros de consulta.
Entradas que geram acertos de cache podem expirar ou ser removidas. Esta verificação demonstra o
reúso a partir de uma localização de cliente; ela não comprova uma taxa global de acertos nem uma
melhoria na latência. Se o verificador informar uma ausência no cache, inspecione
CF-Cache-Status usando o
guia de depuração do Workers Cache.
Duas respostas de sucesso com IDs de invocação diferentes significam que o Worker calculou a
resposta duas vezes.
Use um novo nome de arquivo público e uma nova chave para o objeto de origem ao criar uma nova
versão da origem, como photo-v2.png e public/photo-v2.png, depois
atualize a lista de permissões e implante. Não sobrescreva photo-v1.png.
A versão pública, a largura e o formato diferenciam os corpos de resposta em cache. Uma implantação
usa uma nova versão do cache do Worker por padrão, mas não consegue limpar uma resposta antiga já
armazenada no cache de um navegador.
Entender as falhas e os limites da publicação
Parâmetros inválidos retornam 400, objetos não publicados ou ausentes retornam 404, métodos não
suportados retornam 405 e origens não suportadas retornam 422. Falhas de binding ou transformação
retornam um 502 sem detalhes sensíveis. Toda falha é no-store; origens ausentes
nunca resultam no uso de um original sem transformação como alternativa. Respostas de sucesso em
cache ainda válidas permanecem disponíveis até expirar, mesmo que seu objeto de origem seja
removido. stale-if-error=0 impede que uma resposta de sucesso expirada oculte um erro
posterior do Worker.
Remover uma entrada da lista de permissões e reimplantar altera a versão do cache do Worker. Isso não revoga cópias já baixadas ou armazenadas no cache dos navegadores. Publique apenas conteúdo que você pretende tornar público.
Considerações sobre custos
Confira os preços do R2, preços do Workers e preços do Images atuais antes da implantação. Armazenamento, leituras de objetos, requisições ao Worker e transformações únicas de imagens têm regras de cobrança separadas. A lista fixa de variantes limita as opções para uma origem; novas versões da origem adicionam transformações. Uma ausência no cache pode levar a uma nova leitura e decodificação da origem, mesmo quando essa transformação já foi cobrada.
Remover os recursos da demonstração
Exclua apenas o Worker e o bucket que você criou para este exemplo:
(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)
Confirme que o Worker e o bucket não estão mais na sua conta. Mantenha sua origem local e seus downloads até terminar de verificar os resultados. Se a limpeza parar no meio, retome com os comandos de exclusão restantes para esses mesmos nomes de recursos que pertencem a você.
Agora você tem um fluxo concreto de publicação: um bucket de origem privado, duas variantes públicas escolhidas deliberadamente e uma verificação que distingue um acerto de cache de outra transformação. Para uma alternativa gerenciada de entrega de imagens, conheça o Smart CDN da Transloadit.
