Casi todas las aplicaciones acaban guardando archivos: avatares, adjuntos, exportaciones, copias de seguridad. En Cloudflare eso es R2. Lo que más me gusta de R2 es lo que no cobra: la salida de datos. En S3, servir 1 TB de descargas al mes cuesta unos 90 $; en R2, 0 $.
Para quién es y qué necesitas
Para quien necesita guardar archivos de sus usuarios (adjuntos, fotos, exportaciones) o servir archivos grandes.
Antes de empezar:
- El proyecto de los capítulos anteriores.
- Para las URLs firmadas: un token de API de R2. Lo creamos en el paso correspondiente; ten a mano el id de tu cuenta, que aparece en el panel de Cloudflare.
Al terminar tendrás subidas y descargas a través del Worker, subidas grandes directas a R2 con URL firmada y una idea clara de cuándo guardaría los archivos fuera de Cloudflare.
Cómo encaja R2 en la aplicación
flowchart LR
N[Navegador] -->|1 · pide permiso| W[Worker]
W -->|2 · URL firmada| N
N -->|3 · sube directo| R2[(R2)]
W -->|metadatos| D1[(D1)]
R2 -.->|evento| Q[Queue]
N -->|descarga| CDN[Dominio público<br/>del bucket]
CDN --> R2
Los archivos van a R2 y sus metadatos (quién lo subió, nombre, tamaño, a qué nota pertenece) a D1. Así puedes buscar y listar con SQL sin recorrer el bucket.
Crear el bucket y el binding
npx wrangler r2 bucket create notes-attachments{ "r2_buckets": [ { "binding": "ATTACHMENTS", "bucket_name": "notes-attachments" } ]}Subir y descargar a través del Worker
Para archivos de hasta unas decenas de MB, lo más sencillo es que el Worker reciba el archivo y lo guarde. El cuerpo se pasa como stream, sin cargarlo entero en memoria:
import { Hono } from "hono";
export const attachments = new Hono<{ Bindings: Env }>();
const MAX_BYTES = 20 * 1024 * 1024; // 20 MB
attachments.put("/:noteId/:name", async (c) => { const size = Number(c.req.header("content-length") ?? 0); if (!size || size > MAX_BYTES) { return c.json({ error: "File is empty or too large" }, 413); } const key = `${c.req.param("noteId")}/${crypto.randomUUID()}`; await c.env.ATTACHMENTS.put(key, c.req.raw.body, { httpMetadata: { contentType: c.req.header("content-type") ?? "application/octet-stream", }, customMetadata: { name: c.req.param("name") }, }); return c.json({ key }, 201);});
attachments.get("/:noteId/:id", async (c) => { const key = `${c.req.param("noteId")}/${c.req.param("id")}`; const object = await c.env.ATTACHMENTS.get(key); if (!object) return c.json({ error: "Not found" }, 404); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); return new Response(object.body, { headers });});Subidas grandes: URL firmada y subida directa
La idea, en un dibujo: el Worker solo da permiso, y el archivo va directo del navegador a R2.
sequenceDiagram
participant N as Navegador
participant W as Tu Worker
participant R as R2
N->>W: Quiero subir foto.jpg
W->>W: ¿Tiene permiso sobre esa nota?
W-->>N: URL firmada, válida 10 minutos
N->>R: PUT directo con la URL
R-->>N: 200
N->>W: Ya está subido
El tamaño máximo del cuerpo de una petición depende del plan del dominio (100 MB en Free y Pro). Para archivos grandes, o para no gastar CPU del Worker, el navegador sube directamente a R2 con una URL firmada que caduca en minutos. R2 es compatible con la API de S3, así que se firma con aws4fetch:
npm install aws4fetchnpx wrangler secret put R2_ACCESS_KEY_IDnpx wrangler secret put R2_SECRET_ACCESS_KEYLas claves salen de R2 › Manage API tokens: crea un token con permiso de lectura y escritura solo para ese bucket.
import { AwsClient } from "aws4fetch";
export async function uploadUrl(env: Env, key: string) { const r2 = new AwsClient({ accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY, }); const url = new URL( `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com` + `/notes-attachments/${key}`, ); url.searchParams.set("X-Amz-Expires", "600"); // 10 minutes const signed = await r2.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true }, }); return signed.url;}Para que el navegador pueda subir a otro dominio, el bucket necesita una regla CORS que permita PUT desde tu dominio (R2 › bucket › Settings › CORS Policy).
Descargas públicas con un dominio propio
Si los archivos son públicos (imágenes de un blog, descargas de un producto), conecta un dominio al bucket en R2 › bucket › Settings › Custom Domains (por ejemplo, files.mydomain.dev). Las descargas no pasan por tu Worker, se cachean en la red de Cloudflare y no cuestan nada de salida.
Ciclo de vida y almacenamiento poco frecuente
| Clase | Almacenamiento | Operaciones de escritura (A) / lectura (B) | Cuándo |
|---|---|---|---|
| Standard | 0,015 $/GB-mes | 4,50 $ / 0,36 $ por millón | Lo que se lee a menudo |
| Infrequent Access | 0,01 $/GB-mes | 9 $ / 0,90 $ por millón + 0,01 $/GB recuperado | Copias, exportaciones antiguas. Mínimo 30 días |
Con reglas de ciclo de vida (bucket › Settings › Object lifecycle rules) puedes mover objetos a Infrequent Access pasados X días o borrarlos solos, por ejemplo, los temporales de más de 7 días.
Reaccionar a archivos nuevos
R2 puede enviar un mensaje a una cola cada vez que se crea o borra un objeto. Es la base para generar miniaturas, extraer texto de PDFs o calcular embeddings sin que el usuario espere:
npx wrangler r2 bucket notification create notes-attachments \ --event-type object-create --queue process-attachmentsEl consumidor de esa cola lo vemos en Segundo plano.
Almacenamiento fuera de Cloudflare
R2 no es la respuesta para todo. Estas son las opciones que considero y cuándo:
| Opción | Cuándo la mantendría | Coste de salida |
|---|---|---|
| AWS S3 | El resto del sistema (procesos, análisis, permisos) está en AWS | Unos 0,09 $/GB a internet |
| Google Cloud Storage | Lo mismo, en Google Cloud | Similar a S3 |
| Backblaze B2 | Copias baratas; es socio de Cloudflare y la salida hacia Cloudflare es gratis | Gratis hacia Cloudflare |
| Almacenamiento del proveedor de base de datos (Supabase Storage…) | Si ya usas ese proveedor para todo | Según el proveedor |
Para llevar datos de S3 a R2 hay dos herramientas:
- Super Slurper: copia un bucket entero de una vez.
- Sippy: migración progresiva. R2 sirve lo que ya tiene y, cuando le piden algo que no tiene, lo trae de S3 y se lo queda. Sin parar el servicio.
Cuánto cuesta
Cuánto cuesta al mes
Supuestos: 50 GB guardados, 200.000 subidas y 3 millones de descargas al mes
| Concepto | Importe |
|---|---|
| Almacenamiento40 GB por encima de los 10 GB gratis × 0,015 $ | 0,60 $ |
| Operaciones de clase A (subidas)1 M al mes incluidas | 0,00 $ |
| Operaciones de clase B (descargas)10 M al mes incluidas | 0,00 $ |
| Salida de datosGratis siempre | 0,00 $ |
| Total al mes | 0,60 $ |
Precios consultados el 3 de noviembre de 2026. Pueden cambiar.
| Proveedor | Valor ($) |
|---|---|
| R2 | 0 |
| S3 (a internet) | 90 |
Resumen
- Archivos en R2, metadatos en D1.
- Subidas pequeñas por el Worker; grandes, con URL firmada directa al bucket.
- Dominio propio para archivos públicos; nunca
r2.deven producción. - Siguiente capítulo: tareas en segundo plano.
