CloudflareMódulo II · Datos y archivos · Capítulo 6 de 15Nivel: BásicoEn desarrollo

6. Archivos con R2

Cómo usar R2 para subidas y descargas desde un Worker, URLs firmadas, buckets públicos y cuándo mantener S3 u otro almacenamiento externo.

En desarrollo. La publico antes de terminarla porque ya puede servirte; la iré completando y anotaré los cambios al final.

Víctor Rodríguez5 min de lectura

Probado conTypeScript 7.0Wrangler 4.157Vitest 5.1

Una foto sube del móvil a una caja grande donde se guardan los archivos.

Qué vas a aprender

  • Guardar los archivos de tus usuarios: fotos, documentos, adjuntos.
  • Dejar que suban archivos grandes sin que tu aplicación se resienta.
  • Cuándo guardaría los archivos en otro sitio.

Conocimientos iniciales

Si alguna de estas preguntas no te suena, ábrela: está explicada desde cero.

  • ¿Qué es el almacenamiento de objetos?

    Un almacén para archivos de cualquier tamaño (fotos, PDF, vídeos), cada uno con su nombre. No es una carpeta de tu ordenador: se accede por internet. R2 de Cloudflare y S3 de AWS son de este tipo.

  • ¿Qué es un bucket?

    Cada contenedor de archivos dentro de un almacenamiento de objetos. Por ejemplo, un bucket para los adjuntos de tu aplicación y otro para las copias de seguridad.

  • ¿Qué es una URL firmada?

    Un enlace temporal que da permiso para subir o descargar un archivo concreto durante unos minutos, sin compartir ninguna contraseña.

  • ¿Qué es un binding?Repaso

    La conexión entre tu Worker y otro servicio de Cloudflare (una base de datos, un almacén de archivos). Se declara en wrangler.jsonc y aparece en tu código como env.NOMBRE, sin contraseñas ni direcciones.

    Lo vimos en el capítulo 3.

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

Terminal
npx wrangler r2 bucket create notes-attachments
wrangler.jsonc (extracto)
{
"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:

src/attachments.ts
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:

Terminal
npm install aws4fetch
npx wrangler secret put R2_ACCESS_KEY_ID
npx wrangler secret put R2_SECRET_ACCESS_KEY

Las claves salen de R2 › Manage API tokens: crea un token con permiso de lectura y escritura solo para ese bucket.

src/sign.ts
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:

Terminal
npx wrangler r2 bucket notification create notes-attachments \
--event-type object-create --queue process-attachments

El 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

ConceptoImporte
Almacenamiento40 GB por encima de los 10 GB gratis × 0,015 $0,60 $
Operaciones de clase A (subidas)1 M al mes incluidas0,00 $
Operaciones de clase B (descargas)10 M al mes incluidas0,00 $
Salida de datosGratis siempre0,00 $
Total al mes0,60 $

Precios consultados el 3 de noviembre de 2026. Pueden cambiar.

Coste de servir 1 TB de descargas al mes (solo la salida de datos)0255075100R2: 0 $0S3 (a internet): 90 $90R2S3 (a internet)Proveedor$
Coste de servir 1 TB de descargas al mes (solo la salida de datos)
Coste de servir 1 TB de descargas al mes (solo la salida de datos)
ProveedorValor ($)
R20
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.dev en producción.
  • Siguiente capítulo: tareas en segundo plano.

Recursos

¿Te ha servido? Esto forma parte de un libro que estoy escribiendo: Cloudflare de cero a producción. Ver el índice provisional →