Supabase Storage en la práctica: subida de archivos, CDN y control de acceso

La semana pasada un lector preguntó: «¿S3 o Cloudflare R2 para avatares de usuario?»
Lo he vivido en dos proyectos. S3 con políticas IAM es un dolor; R2 es barato pero montas auth aparte. Migré a Supabase Storage —no es bala de plata— pero si ya usas Auth y base de datos, el combo funciona muy bien.
Aquí van mecanismos, tres modos de acceso, trampas de archivos grandes, CDN y costos frente a R2/S3. Código ejecutable.
1. Arquitectura central de Supabase Storage
Primero: Supabase Storage usa AWS S3 por debajo.
Una capa fina encima: operas con el SDK JavaScript sin IAM ni credenciales AWS complejas.
Vinculación automática con Auth
Lo que más me gusta: creas un bucket y el JWT de supabase.auth.getUser() controla quién sube y descarga. Sin otro sistema de permisos.
// La subida lleva identidad de usuario
const { data, error } = await supabase.storage
.from('avatars')
.upload('user-123/profile.jpg', file)
Debajo revisa RLS —más adelante cómo configurarlo.
CDN global automático
Los archivos se sirven por Cloudflare CDN sin configurar CloudFront ni Workers.
En DevTools, mira cf-cache-status:
cf-cache-status: HIT
HIT = caché; MISS = no. Public bucket suele tener mejor hit rate que Private.
Smart CDN: invalidación automática
Tradicionalmente purgas caché o esperas TTL. Smart CDN sincroniza metadata al edge; tras actualizar, efecto global en hasta 60 s.
Para tiempo real estricto, usa cacheNonce más adelante.
2. Tres modos de control de acceso
Public, Private y Signed URL —tres escenarios. Elegir mal baja caché o rompe seguridad.
2.1 Public Bucket: recursos abiertos
Logo, imágenes de blog, docs públicos → Public bucket.
Ventajas:
- URL simple:
https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg - Máximo hit rate; CDN responde sin Auth
- Código mínimo
const { data } = supabase.storage
.from('public-images')
.getPublicUrl('hero-banner.jpg')
console.log(data.publicUrl)
Casos: avatares públicos, blog, estáticos, documentos abiertos.
2.2 Private Bucket + Signed URL
Contratos, contenido premium, docs sensibles → Private + URL firmada temporal.
const { data, error } = await supabase.storage
.from('private-docs')
.createSignedUrl('contracts/user-123.pdf', 3600)
console.log(data.signedUrl)
Cada Signed URL es distinta → peor caché. Reutiliza en frontend/Redis si el mismo usuario accede pronto.
2.3 Políticas RLS: permisos finos
En storage.objects defines quién hace qué.
Solo carpeta propia:
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
Admin accede a todo:
CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
auth.jwt() ->> 'role' = 'admin'
);
Solo miembros descargan premium:
CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
bucket_id = 'premium-content'
AND EXISTS (
SELECT 1 FROM user_subscriptions
WHERE user_id = auth.uid()
AND status = 'active'
)
);
Con estas tres cubres la mayoría de casos.
3. Subida de archivos en la práctica
3.1 Subida estándar (<5 MB)
import { useState } from 'react'
import { supabase } from './supabase-client'
export function AvatarUpload() {
const [uploading, setUploading] = useState(false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const filePath = `avatars/${fileName}`
const { error } = await supabase.storage
.from('public-images')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
alert('Error de subida: ' + error.message)
} else {
const { data } = supabase.storage
.from('public-images')
.getPublicUrl(filePath)
setAvatarUrl(data.publicUrl)
}
setUploading(false)
}
return (
<div>
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
{avatarUrl && <img src={avatarUrl} alt="avatar" />}
{uploading && <p>Subiendo...</p>}
</div>
)
}
cacheControl: caché del navegador (no CDN)upsert: falseevita sobrescritura accidental- Timestamp o UUID en el nombre
3.2 TUS: archivos grandes
>5 MB o red inestable → TUS.
chunkSize obligatorio: 6 MB. URL válida 24 h.
npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
export function LargeFileUploader() {
const uppy = new Uppy({
restrictions: {
maxFileSize: 100 * 1024 * 1024,
allowedFileTypes: ['video/*', 'image/*']
}
})
const getSession = async () => {
const { data: { session } } = await supabase.auth.getSession()
return session?.access_token || ''
}
uppy.use(Tus, {
endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
chunkSize: 6 * 1024 * 1024,
async onBeforeRequest(req) {
const token = await getSession()
req.setHeader('Authorization', `Bearer ${token}`)
}
})
return <Dashboard uppy={uppy} />
}
Si se queda en 6 MB: verifica chunkSize exacto, token no expirado, RLS permite INSERT.
3.3 Presigned Upload URL
Subida desde cliente sin exponer service_role:
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUploadUrl('documents/report.pdf')
4. CDN e imágenes
4.1 Smart CDN
60 s de propagación a veces molesta.
- Nueva ruta en cada actualización (
logo-${version}.png) - cacheNonce para saltar caché
- Reutilizar Signed URL
4.2 Transformación de imágenes
Límites: 1-2500 px, ≤25 MB, ≤50 MP.
const { data } = supabase.storage
.from('images')
.getPublicUrl('hero.jpg', {
transform: {
width: 300,
height: 200,
resize: 'cover',
quality: 80,
format: 'webp'
}
})
Integración Next.js con loader personalizado. Facturación: $5/1000 imágenes origin.
5. Costos y elección
Muchos se fijan en el precio. Aquí va una comparativa directa.
5.1 Tabla de precios
| Servicio | Almacenamiento | Egress | Gratis | Notas |
|---|---|---|---|---|
| Supabase Storage | Precio S3 | CDN aparte | En Pro | Auth, RLS |
| Cloudflare R2 | $0.015/GB | $0 | 10 GB | Sin egress |
| AWS S3 | $0.023/GB | $0.09/GB | 5 GB/12 meses | Ecosistema |
| DigitalOcean Spaces | $5/250GB | Incluido | — | Tarifa fija |
5.2 Cómo elegir
Mucho volumen de descargas → R2
Si tus archivos se descargan a menudo (sitio de imágenes, vídeo), el egress cero de R2 ahorra mucho. S3 cobra casi 10 centavos por GB de salida; con tráfico alto duele.
Auth integrado → Supabase Storage
Si ya usas Auth y base de datos de Supabase, Storage encaja bien: permisos y RLS se reutilizan.
Ecosistema AWS → S3
Lambda, CloudFront, S3 Select, Glacier: si ya estás en AWS, cambiar puede costar más de lo que ahorras.
Presupuesto fijo y tráfico predecible → DigitalOcean Spaces
Tarifa mensual fija, ideal para proyectos pequeños sin sorpresas en la factura.
5.3 Optimización de costos
- Políticas de lifecycle: archivos viejos a Glacier u otro tier frío
- Compresión de imágenes: antes de subir o con transformación de Supabase
- Public bucket para subir el hit rate de caché
- Reutilizar Signed URL y reducir MISS por regeneración
6. Solución de problemas
6.1 Versión antigua tras actualizar
Causa: retraso de propagación de Smart CDN (~60 s).
Solución:
- Espera 60 segundos
- Sube a una ruta nueva
- Usa
cacheNoncepara saltar caché
6.2 TUS bloqueado en 6 MB
Causa: chunkSize mal configurado.
Solución: chunkSize: 6 * 1024 * 1024, exacto en bytes.
// Incorrecto: 5 MB
chunkSize: 5 * 1024 * 1024 // se queda colgado
// Correcto: 6 MB
chunkSize: 6 * 1024 * 1024
6.3 403 Forbidden
Causa: políticas RLS mal definidas.
Pasos:
- Comprueba si el bucket es Public o Private
- Revisa RLS en
storage.objects - Asegura una política que permita INSERT
-- Ver políticas existentes
SELECT * FROM pg_policies WHERE tablename = 'objects';
-- Permitir subida
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');
6.4 Signed URL no funciona
Causa: URL caducada o token inválido.
Solución:
- Revisa que el TTL sea razonable
- Comprueba que el token no esté truncado
- En pruebas, genera una URL larga (p. ej. 24 h)
Resumen
- Public para abiertos, mejor caché
- Private + Signed URL para privados
- RLS para control fino
- TUS con chunkSize 6 MB
- Smart CDN hasta 60 s de retraso
- R2 si hay mucho egress; Supabase si ya usas Auth
¿Preguntas o trampas que hayas visto? Compártelas en comentarios.
Referencias
- Storage CDN | Supabase Docs
- Storage Access Control | Supabase Docs
- Resumable Uploads | Supabase Docs
- Image Transformations | Supabase Docs
- Smart CDN | Supabase Docs
- Cloud Storage Pricing | BuildMVPFast
- Supabase Storage v3: Resumable Uploads
- GitHub Issue #563 - TUS Upload Stalling
Flujo completo de subida en Supabase Storage
Desde crear el bucket hasta configurar RLS para subidas seguras y controladas
⏱️ Estimated time: 30 min
- 1
Step 1: Crear Storage Bucket
En Supabase Dashboard:
• Storage → "Create a new bucket"
• Nombre (avatars, documents, etc.)
• Modo Public o Private
• Public: acceso directo; Private: requiere Signed URL - 2
Step 2: Configurar políticas RLS
En storage.objects:
```sql
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
```
• bucket_id del bucket objetivo
• auth.uid() = ID del usuario
• storage.foldername() parsea la ruta - 3
Step 3: Subida estándar
upload para archivos <5 MB:
```typescript
const { error } = await supabase.storage
.from('bucket-name')
.upload('path/file.jpg', file, {
cacheControl: '3600',
upsert: false
});
```
• cacheControl: caché del navegador
• upsert: false evita sobrescribir - 4
Step 4: Configurar subida TUS
Archivos grandes (>5 MB):
• npm install @uppy/tus tus-js-client
• chunkSize: 6 * 1024 * 1024 (obligatorio 6 MB)
• Header Authorization con JWT
• URL de subida válida 24 horas - 5
Step 5: Optimizar caché CDN
• Public bucket = mayor hit rate
• Archivos que cambian a menudo: nueva ruta, no sobrescribir
• cacheNonce para forzar refresco
• Reutilizar Signed URL
FAQ
¿Por qué chunkSize debe ser 6 MB en Supabase Storage?
¿Public o Private bucket?
• Private: archivos de usuario, contenido de membresía, documentos sensibles —Signed URL o RLS
¿Por qué sigo viendo la versión antigua tras actualizar?
¿Supabase Storage o Cloudflare R2?
• Auth integrado: Supabase Storage con RLS
• Ya usas Supabase: Storage encaja natural
• Solo objeto puro: R2 más barato
¿RLS para que cada usuario solo acceda a sus archivos?
```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```
¿Baja tasa de acierto con Signed URL?
6 min de lectura · Publicado el: 14 abr 2026 · Actualizado el: 21 ago 2026
Supabase en práctica
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Supabase Realtime en la práctica: gestión de conexiones WebSocket y estrategias de reconexión
Guía práctica de Supabase Realtime: gestión de conexiones WebSocket, estrategias de reconexión y suscripciones en tiempo real con Postgres Changes. Broadcast, Presence y Postgres Changes: cuándo usar cada uno y buenas prácticas en producción
Parte 6 de 10
Siguiente
Supabase Edge Functions en la práctica: runtime Deno y guía de desarrollo con TypeScript
Guía completa de Supabase Edge Functions: arquitectura del runtime Deno e isolate V8, flujo de comandos CLI, API RESTful con Hono, del depurado local al despliegue en producción
Parte 8 de 10



Comentarios
Inicia sesión con GitHub para dejar un comentario