R2 es el S3 que ya conoces, con una factura que no castiga tu tráfico
Cero costes de egress, API compatible con S3, signed URLs para archivos privados, multipart uploads para los grandes. Diseñamos R2 dentro de tu aplicación para que la línea de storage en la factura deje de ser la sorpresa que normalmente es.
Por qué este stack
El cero de egress es la diferencia
S3 cobra por petición y por gigabyte leído. R2 cobra por petición y por gigabyte almacenado, punto. Para una aplicación que sirve el mismo archivo repetidamente (avatares, imágenes, assets estáticos, media), la factura cae en un orden de magnitud. El número exacto depende del patrón de lectura; lo modelamos antes de firmar.
API compatible con S3, así tu tooling funciona
El AWS SDK habla con R2 cambiando una línea de config. Migraciones existentes, librerías, scripts CLI e infrastructure-as-code: todo funciona. Migrar no es reescribir; es un cambio de credenciales más un paso de copia.
Pegado al edge de Cloudflare cuando hace falta
R2 vive en la misma red global que Workers, Cache e Images. Añadir transformaciones de imagen, validación de signed URLs en el edge o autorización por petición no cuesta un round trip extra. Las piezas componen.
Pricing predecible, sin facturas sorpresa
Coste de storage, coste por petición, sin coste de egress. Tres líneas, todas lineales con el uso. La famosa sorpresa de coste que golpea a los equipos en S3 con tráfico de lectura alto no existe en R2. La factura pasa a ser un número de presupuesto.
Aislamiento multi-tenant que escala de verdad
Una cuenta R2, muchos buckets, o un solo bucket con prefijo de clave por tenant. Los signed URLs aplican el límite en el momento de emisión. Elegimos el patrón según los requisitos de aislamiento y la complejidad operativa, no según lo que está de moda.
Qué construimos con esta tecnología
Setup del bucket R2 con IAM correcta
Tokens scope-de-cuenta, tokens scope-de-bucket, credenciales separadas para consumers de solo lectura (web app) frente a producers de escritura completa (backend).
Generación de signed URLs
URLs pre-firmadas para upload y download, TTL configurado por caso de uso, firma validada en servidor o en edge vía un Worker.
Configuración de la policy CORS
Allowlist de orígenes ajustada a los dominios de producción, allowlist de métodos por caso de uso, preflight cacheado para performance.
Pipeline de upload de imágenes
El client pide un signed URL, sube directamente a R2, el servidor valida y escribe la fila de metadatos, opcionalmente con escaneo antivirus y transformación de imagen entre medio.
Metadatos del archivo en la base de datos
Una tabla `files` en Postgres lleva el registro canónico (owner, tamaño, tipo, sha256, status, soft-delete). R2 son los bytes; la base de datos es la verdad.
Migración desde S3
Bucket existente replicado a R2, config de aplicación cambiada, bucket antiguo deprecado con ventana de rollback. Lo hemos hecho end of file; el playbook es reusable.
Estrategia CDN
CDN nativa de Cloudflare para los assets cacheables (por defecto), CDN de Vercel para el estático de aplicación, o una CDN de terceros cuando el buyer la pide. El origen R2 se queda igual.
Estructura de bucket multi-tenant
O bucket-por-tenant (aislamiento alto, más trabajo operativo) o bucket único con prefijo de clave `tenant_id/` (aislamiento más bajo, ops más simples). Documentamos el trade-off y el buyer firma.
Backup y reglas de lifecycle
Policies de lifecycle para tiers de cold storage, replicación off-account para disaster recovery, snapshots programados donde el régimen de compliance los exige.
Tracking de cuota por tenant
Un job nocturno suma el storage por tenant, escribe el resultado en la base de datos, hace surgir los excesos en el dashboard de administración.
Integración con Workers
Transformaciones de imagen, validación de signed URL, lógica de routing custom en el edge sin un servicio extra que operar.
Pipeline de transformación de imágenes
Resize, conversión de formato (AVIF, WebP), tuning de calidad, todo en el edge. Ahorra ancho de banda y mejora Core Web Vitals sin añadir un servicio de imágenes aparte.
Uploads con signed URL a R2 con metadatos del lado de la base de datos
El client recibe una URL pre-firmada, sube directamente a R2, luego notifica al servidor, que valida y escribe la fila canónica `files`. R2 guarda los bytes; Postgres guarda la verdad.
La mayoría de guías "R2 quickstart" te muestra el dashboard y se queda ahí. R2 en producción es el pipeline de upload de abajo: el client nunca recibe la secret key, el archivo va directo a R2 sin pasar por proxy por tu servidor, y la fila canónica de metadatos se escribe solo después de que el upload completa. Tres archivos; sin costes sorpresa, sin credenciales filtradas, sin archivos huérfanos en el bucket.
1. URL de upload pre-firmada
Una Server Action acuña una URL PUT pre-firmada con TTL corto. El client sube directamente a R2 usando esa URL; el servidor nunca ve los bytes.
// app/[lang]/(app)/upload/actions.ts
'use server'
import { z } from 'zod'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { randomUUID } from 'crypto'
import { r2 } from '@/lib/r2/client'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
filename: z.string().min(1).max(255),
contentType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
size: z.number().int().positive().max(50 * 1024 * 1024),
})
export async function requestUploadUrl(
raw: unknown,
): Promise<{ ok: true; key: string; url: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
const key = `${session.tenantId}/${randomUUID()}-${parsed.data.filename}`
const url = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: parsed.data.contentType,
ContentLength: parsed.data.size,
}),
{ expiresIn: 60 },
)
return { ok: true, key, url }
}
2. El client R2
El AWS SDK apunta a R2 con un cambio de config: el endpoint. Todo lo demás se mantiene estándar, así que tooling AWS y CLIs existentes funcionan sin modificación.
// src/lib/r2/client.ts
import { S3Client } from '@aws-sdk/client-s3'
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
3. El client sube directamente a R2
El navegador usa la URL pre-firmada con una sola petición PUT. El evento de progress alimenta la UI; sin round-trip al servidor por cada chunk.
// app/[lang]/(app)/upload/UploadForm.tsx
'use client'
import { useState } from 'react'
import { requestUploadUrl } from './actions'
import { confirmUpload } from './confirm'
export function UploadForm() {
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
async function handleFile(file: File) {
setError(null)
setProgress(0)
const reqResult = await requestUploadUrl({
filename: file.name,
contentType: file.type,
size: file.size,
})
if (!reqResult.ok) {
setError(reqResult.error)
return
}
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', reqResult.url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve()
else reject(new Error(`R2 returned ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('upload failed')))
xhr.send(file)
})
await confirmUpload({ key: reqResult.key, size: file.size })
}
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
{progress > 0 && progress < 100 ? <progress value={progress} max={100} /> : null}
{error ? <p role="alert">{error}</p> : null}
</div>
)
}
4. El paso de confirmación escribe la fila de metadatos
El client le dice al servidor "el upload tuvo éxito para esta clave". El servidor verifica que el objeto exista en R2 (petición HEAD), y luego escribe la fila canónica files.
// app/[lang]/(app)/upload/confirm.ts
'use server'
import { HeadObjectCommand } from '@aws-sdk/client-s3'
import { z } from 'zod'
import { r2 } from '@/lib/r2/client'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
key: z.string().min(1).max(500),
size: z.number().int().positive(),
})
export async function confirmUpload(
raw: unknown,
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Ata la clave al tenant autenticado; el prefijo codifica el tenant.
if (!parsed.data.key.startsWith(`${session.tenantId}/`)) {
return { ok: false, error: 'invalid key' }
}
// Verifica que el objeto haya aterrizado de verdad en R2.
let head
try {
head = await r2.send(
new HeadObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: parsed.data.key,
}),
)
} catch {
return { ok: false, error: 'object not found in R2' }
}
const supabase = await createServerClient()
const { data, error } = await supabase
.from('files')
.insert({
tenant_id: session.tenantId,
uploader_id: session.userId,
r2_key: parsed.data.key,
content_type: head.ContentType ?? 'application/octet-stream',
size_bytes: head.ContentLength ?? parsed.data.size,
status: 'ready',
})
.select('id')
.single()
if (error) return { ok: false, error: error.message }
return { ok: true, id: data.id }
}
5. Qué te compra esto
El servidor nunca ve los bytes, así que el upload escala independientemente de tu presupuesto de ejecución de funciones. El prefijo R2 codifica el tenant, así que un client malicioso no puede subir al namespace de otro tenant ni siquiera con una URL firmada filtrada. La fila canónica de metadatos se escribe solo después de que el upload tiene éxito, así que los archivos huérfanos en el bucket no son un problema. La factura cae a coste de storage y coste por petición; el egress es cero tanto si sirves el archivo una vez como un millón.
Esa es la línea de coste con la que el marketing de R2 abre la presentación, configurada en un flujo real de producción en vez de dejada como pantallazo en un tour del dashboard.
Preguntas frecuentes
¿R2 frente a S3 — cuándo elegir cuál?
R2 cuando el tráfico de lectura no es trivial (cualquier aplicación de cara al cliente que sirve los mismos archivos repetidamente). S3 cuando la integración con el ecosistema AWS (Athena, Glue, Lambda, EventBridge) es el atractivo principal y el tráfico de lectura es ligero. Para la mayoría de los SaaS, R2 gana en factura, empata en tooling.
¿Cómo se comparan de verdad los costes de egress?
S3 cobra alrededor de nueve céntimos por gigabyte saliente (los primeros 10 TB; más barato a escala). R2 cobra cero por gigabyte saliente, punto. Para una aplicación que sirve 1 TB al mes de avatares e imágenes, son cien dólares ahorrados al mes por terabyte. El tráfico aplicativo se acumula; el ahorro también.
¿Cuánto dura la migración desde S3?
Una semana para una aplicación mediana. La copia en sí corre en background vía `rclone` o `aws cli sync`; el cutover es un día con la aplicación leyendo de ambos durante una ventana breve, luego solo de R2. Tenemos un runbook.
¿Podemos hacer transformaciones de imagen sobre R2?
Sí, vía Cloudflare Images o un Worker que lee desde R2 y emite variantes redimensionadas. Elegimos según el volumen y los formatos requeridos; ambos guardan los bytes originales en R2.
¿Cómo aplicáis el aislamiento por tenant?
Bucket-por-tenant para el caso más estricto (industrias reguladas, clientes enterprise grandes). Prefix-por-tenant con aplicación vía signed URL para todo lo demás. El código aplicativo no sabe la diferencia; la función helper elige la clave correcta basándose en un único flag de config.
¿Y la CDN — todavía hace falta?
La red de Cloudflare está delante de R2 por defecto para el caso cacheable. Para el estático aplicativo (`_next/static` de Next.js) la CDN de Vercel ya está. Añadimos una CDN aparte solo cuando el buyer necesita routing específico por región o un proveedor en particular para compliance.
¿R2 cumple con data residency UE?
R2 soporta buckets con region-lock donde los datos se quedan en la UE. Documentamos la postura de residency y configuramos la región del bucket en el contrato. El código aplicativo no tiene que cambiar.
Cuéntanos tu workload de storage
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Nuevos deploys R2 y migraciones desde S3 igual de bienvenidos.