R2 è l'S3 che già conosci, con una fattura che non punisce il traffico
Zero costi di egress, API compatibile S3, signed URL per i file privati, multipart upload per quelli grandi. Progettiamo R2 nella tua applicazione così la voce storage in fattura smette di essere la sorpresa che di solito è.
Perché questo stack
Lo zero costo di egress è la differenza
S3 fattura per richiesta e per gigabyte letto. R2 fattura per richiesta e per gigabyte conservato, punto. Per un'applicazione che serve lo stesso file ripetutamente (avatar, immagini, asset statici, media), la fattura scende di un ordine di grandezza. Il numero esatto dipende dal pattern di lettura; lo modelliamo prima di firmare.
API compatibile S3, quindi il tuo tooling funziona
L'AWS SDK parla con R2 cambiando una sola riga di config. Migration esistenti, librerie, script CLI e infrastructure-as-code funzionano tutti. Migrare non è una riscrittura; è uno scambio di credenziali più uno step di copia.
Legato all'edge Cloudflare quando serve
R2 sta sulla stessa rete globale di Workers, Cache e Images. Aggiungere trasformazioni immagini, validazione di signed URL in edge o autorizzazione per richiesta non costa un round trip extra. I pezzi si compongono.
Pricing prevedibile, niente fatture a sorpresa
Costo di storage, costo per richiesta, niente costo di egress. Tre voci, tutte lineari con l'uso. La famosa sorpresa di costo che colpisce i team su S3 con traffico di lettura alto non esiste su R2. La fattura diventa un numero di budget.
Isolamento multi-tenant che scala davvero
Un account R2, molti bucket, oppure un bucket con prefix di chiave per tenant. I signed URL applicano il confine al momento dell'emissione. Scegliamo il pattern in base ai requisiti di isolamento e alla complessità operativa, non in base a cosa è di moda.
Cosa sviluppiamo con questa tecnologia
Setup del bucket R2 con IAM corretta
Token account-scoped, token bucket-scoped, credenziali separate per consumer in sola lettura (web app) e producer in piena scrittura (backend).
Generazione di signed URL
URL pre-firmati per upload e download, TTL configurato per caso d'uso, firma validata server-side o in edge tramite un Worker.
Configurazione della policy CORS
Allowlist dei domini di produzione stretta, allowlist dei metodi per caso d'uso, preflight cachato per performance.
Pipeline di upload immagini
Il client chiede un signed URL, fa upload diretto su R2, il server valida e scrive la riga di metadati, scansione antivirus e trasformazione immagini opzionali in mezzo.
Metadati dei file nel database
Una tabella `files` su Postgres porta il record canonico (proprietario, dimensione, tipo, sha256, stato, soft-delete). R2 sono i byte; il database è la verità.
Migrazione da S3
Bucket esistente replicato su R2, config applicativa scambiata, vecchio bucket dismesso con finestra di rollback. Lo abbiamo già fatto diverse volte; il playbook è riusabile.
Strategia CDN
CDN nativa Cloudflare per gli asset cacheabili (default), CDN di Vercel per lo statico applicativo, o CDN di terzi dove il buyer la richiede. L'origine R2 resta la stessa.
Struttura bucket multi-tenant
O bucket-per-tenant (massimo isolamento, più lavoro operativo) o singolo bucket con prefix di chiave `tenant_id/` (isolamento minore, ops più semplice). Documentiamo il trade-off e il buyer firma.
Backup e regole di lifecycle
Policy di lifecycle per tier di cold storage, replicazione off-account per disaster recovery, snapshot schedulati dove il regime di compliance li richiede.
Tracking della quota per tenant
Un job notturno conta lo storage per tenant, scrive il risultato nel database, fa emergere gli sforamenti nella dashboard di amministrazione.
Integrazione Workers
Trasformazioni immagini, validazione di signed URL, logica di routing custom in edge senza un servizio extra da gestire.
Pipeline di trasformazione immagini
Resize, conversione formato (AVIF, WebP), tuning della qualità, tutto in edge. Risparmia banda e migliora i Core Web Vitals senza aggiungere un servizio immagini separato.
Upload con signed URL su R2 con metadati lato database
Il client riceve un URL pre-firmato, fa upload diretto su R2, poi notifica il server, che valida e scrive la riga canonica `files`. R2 tiene i byte; Postgres tiene la verità.
La maggior parte delle guide "R2 quickstart" ti mostra la dashboard e si ferma. R2 in produzione è la pipeline di upload qui sotto: il client non riceve mai la secret key, il file va dritto a R2 senza fare proxy dal tuo server e la riga di metadati canonica viene scritta solo dopo che l'upload è andato a buon fine. Tre file; niente costi a sorpresa, niente credenziali trapelate, niente file orfani nel bucket.
1. URL di upload pre-firmato
Una Server Action emette un URL PUT pre-firmato con TTL breve. Il client fa upload diretto su R2 usando quell'URL; il server non vede mai i byte.
// 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. Il client R2
L'AWS SDK punta a R2 cambiando una sola riga di config: l'endpoint. Tutto il resto resta standard, così tooling AWS e CLI esistenti funzionano senza modifiche.
// 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. Il client fa upload diretto su R2
Il browser usa l'URL pre-firmato con una singola richiesta PUT. L'evento di progress guida la UI; niente round-trip al server per ogni 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. Lo step di conferma scrive la riga di metadati
Il client dice al server "l'upload è riuscito per questa chiave". Il server verifica che l'oggetto esista in R2 (richiesta HEAD), poi scrive la riga canonica 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' }
// Lega la chiave al tenant autenticato; il prefisso codifica il tenant.
if (!parsed.data.key.startsWith(`${session.tenantId}/`)) {
return { ok: false, error: 'invalid key' }
}
// Verifica che l'oggetto sia atterrato davvero su 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. Cosa ti compra questo
Il server non vede mai i byte, quindi l'upload scala indipendentemente dal tuo budget di esecuzione delle funzioni. Il prefix R2 codifica il tenant, quindi un client malevolo non può fare upload nel namespace di un altro tenant nemmeno con un signed URL leakato. La riga canonica di metadati viene scritta solo dopo che l'upload riesce, quindi i file orfani nel bucket non sono un problema. La fattura cala su costo di storage e costo per richiesta; l'egress è zero che tu serva il file una volta o un milione.
Questo è la voce di costo che il marketing di R2 mette in vetrina, configurata in un flusso reale di produzione invece di lasciata come screenshot in un tour della dashboard.
Domande frequenti
R2 rispetto a S3 — quando scegliere quale?
R2 quando il traffico di lettura non è banale (qualunque applicazione customer-facing che serve gli stessi file ripetutamente). S3 quando l'integrazione con l'ecosistema AWS (Athena, Glue, Lambda, EventBridge) è il punto di forza e il traffico di lettura è leggero. Per la maggior parte delle SaaS, R2 vince sulla fattura, pareggia sul tooling.
Come si confrontano davvero i costi di egress?
S3 fattura circa nove centesimi per gigabyte in uscita (per i primi 10 TB; più conveniente a scala). R2 fattura zero per gigabyte in uscita, punto. Per un'applicazione che serve 1 TB al mese di avatar e immagini, sono cento dollari risparmiati al mese per terabyte. Il traffico applicativo si accumula; il risparmio pure.
Quanto dura la migrazione da S3?
Una settimana per un'applicazione media. La copia in sé gira in background via `rclone` o `aws cli sync`; il cutover è un giorno con l'applicazione che legge da entrambi per una finestra breve, poi solo da R2. Abbiamo un runbook.
Possiamo fare trasformazioni immagini su R2?
Sì, via Cloudflare Images o un Worker che legge da R2 ed emette varianti ridimensionate. Scegliamo in base al volume e ai formati richiesti; in entrambi i casi i byte originali restano su R2.
Come applicate l'isolamento per tenant?
Bucket-per-tenant nel caso più rigoroso (industrie regolate, clienti enterprise grandi). Prefix-per-tenant con applicazione via signed URL per tutto il resto. Il codice applicativo non sa la differenza; la funzione di supporto sceglie la chiave giusta in base a un singolo flag di config.
E la CDN — serve ancora?
La rete di Cloudflare sta davanti a R2 di default per il caso cacheabile. Per lo statico applicativo (`_next/static` di Next.js) la CDN di Vercel è già lì. Aggiungiamo una CDN separata solo dove il buyer chiede routing region-specific o un provider particolare per compliance.
R2 è compliant con la data residency UE?
R2 supporta bucket region-locked dove i dati restano in UE. Documentiamo il modello di residency e configuriamo la regione del bucket nel contratto. Il codice applicativo non deve cambiare.
Raccontaci il workload di storage
Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Nuovi deploy R2 e migrazioni da S3 entrambi benvenuti.